diff options
Diffstat (limited to 'src/android/app/src/main/java')
97 files changed, 11664 insertions, 0 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt new file mode 100644 index 000000000..c11b6bc16 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.Surface +import android.view.View +import android.widget.TextView +import androidx.annotation.Keep +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath +import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize +import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri +import org.yuzu.yuzu_emu.utils.Log.error +import org.yuzu.yuzu_emu.utils.Log.verbose +import org.yuzu.yuzu_emu.utils.Log.warning +import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable +import java.lang.ref.WeakReference + +/** + * Class which contains methods that interact + * with the native side of the Yuzu code. + */ +object NativeLibrary { + /** + * 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 + + /** + * Controller type for each device + */ + const val ProController = 3 + const val Handheld = 4 + const val JoyconDual = 5 + const val JoyconLeft = 6 + const val JoyconRight = 7 + const val GameCube = 8 + const val Pokeball = 9 + const val NES = 10 + const val SNES = 11 + const val N64 = 12 + const val SegaGenesis = 13 + + @JvmField + var sEmulationActivity = WeakReference<EmulationActivity?>(null) + + init { + try { + System.loadLibrary("yuzu-android") + } catch (ex: UnsatisfiedLinkError) { + error("[NativeLibrary] $ex") + } + } + + @Keep + @JvmStatic + fun openContentUri(path: String?, openmode: String?): Int { + return if (isNativePath(path!!)) { + YuzuApplication.documentsTree!!.openContentUri(path, openmode) + } else openContentUri(appContext, path, openmode) + } + + @Keep + @JvmStatic + fun getSize(path: String?): Long { + return if (isNativePath(path!!)) { + YuzuApplication.documentsTree!!.getFileSize(path) + } else getFileSize(appContext, path) + } + + /** + * Returns true if pro controller isn't available and handheld is + */ + external fun isHandheldOnly(): Boolean + + /** + * Changes controller type for a specific device. + * + * @param Device The input descriptor of the gamepad. + * @param Type The NpadStyleIndex of the gamepad. + */ + external fun setDeviceType(Device: Int, Type: Int): Boolean + + /** + * Handles event when a gamepad is connected. + * + * @param Device The input descriptor of the gamepad. + */ + external fun onGamePadConnectEvent(Device: Int): Boolean + + /** + * Handles event when a gamepad is disconnected. + * + * @param Device The input descriptor of the gamepad. + */ + external fun onGamePadDisconnectEvent(Device: Int): Boolean + + /** + * Handles button press events for a gamepad. + * + * @param Device The input descriptor of the gamepad. + * @param Button Key code identifying which button was pressed. + * @param Action Mask identifying which action is happening (button pressed down, or button released). + * @return If we handled the button press. + */ + external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean + + /** + * Handles joystick movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis The axis ID + * @param x_axis The value of the x-axis represented by the given ID. + * @param y_axis The value of the y-axis represented by the given ID. + */ + external fun onGamePadJoystickEvent( + Device: Int, + Axis: Int, + x_axis: Float, + y_axis: Float + ): Boolean + + /** + * Handles motion events. + * + * @param delta_timestamp The finger id corresponding to this event + * @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor. + * @param accel_x,accel_y,accel_z The value of the y-axis + */ + external fun onGamePadMotionEvent( + Device: Int, + delta_timestamp: Long, + gyro_x: Float, + gyro_y: Float, + gyro_z: Float, + accel_x: Float, + accel_y: Float, + accel_z: Float + ): Boolean + + /** + * Signals and load a nfc tag + * + * @param data Byte array containing all the data from a nfc tag + */ + external fun onReadNfcTag(data: ByteArray?): Boolean + + /** + * Removes current loaded nfc tag + */ + external fun onRemoveNfcTag(): Boolean + + /** + * Handles touch press events. + * + * @param finger_id The finger id corresponding to this event + * @param x_axis The value of the x-axis. + * @param y_axis The value of the y-axis. + */ + external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float) + + /** + * Handles touch movement. + * + * @param x_axis The value of the instantaneous x-axis. + * @param y_axis The value of the instantaneous y-axis. + */ + external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float) + + /** + * Handles touch release events. + * + * @param finger_id The finger id corresponding to this event + */ + external fun onTouchReleased(finger_id: Int) + + external fun reloadSettings() + + external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String? + + external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?) + + external fun initGameIni(gameID: String?) + + /** + * Gets the embedded icon within the given ROM. + * + * @param filename the file path to the ROM. + * @return a byte array containing the JPEG data for the icon. + */ + external fun getIcon(filename: String): ByteArray + + /** + * Gets the embedded title of the given ISO/ROM. + * + * @param filename The file path to the ISO/ROM. + * @return the embedded title of the ISO/ROM. + */ + external fun getTitle(filename: String): String + + external fun getDescription(filename: String): String + + external fun getGameId(filename: String): String + + external fun getRegions(filename: String): String + + external fun getCompany(filename: String): String + + external fun setAppDirectory(directory: String) + + external fun initializeGpuDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String? + ) + + external fun reloadKeys(): Boolean + + external fun initializeEmulation() + + external fun defaultCPUCore(): Int + + /** + * Begins emulation. + */ + external fun run(path: String?) + + /** + * Begins emulation from the specified savestate. + */ + external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean) + + // Surface Handling + external fun surfaceChanged(surf: Surface?) + + external fun surfaceDestroyed() + + /** + * Unpauses emulation from a paused state. + */ + external fun unPauseEmulation() + + /** + * Pauses emulation. + */ + external fun pauseEmulation() + + /** + * Stops emulation. + */ + external fun stopEmulation() + + /** + * Resets the in-memory ROM metadata cache. + */ + external fun resetRomMetadata() + + /** + * Returns true if emulation is running (or is paused). + */ + external fun isRunning(): Boolean + + /** + * Returns the performance stats for the current game + */ + external fun getPerfStats(): DoubleArray + + /** + * Notifies the core emulation that the orientation has changed. + */ + external fun notifyOrientationChange(layout_option: Int, rotation: Int) + + enum class CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown + } + + private var coreErrorAlertResult = false + private val coreErrorAlertLock = Object() + + class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val title = requireArguments().serializable<String>("title") + val message = requireArguments().serializable<String>("message") + + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button, null) + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + coreErrorAlertResult = false + synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + coreErrorAlertResult = true + synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } + } + + companion object { + 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 + } + } + } + + private fun onCoreErrorImpl(title: String, message: String) { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + error("[NativeLibrary] EmulationActivity not present") + return + } + + val fragment = CoreErrorDialogFragment.newInstance(title, message) + fragment.show(emulationActivity.supportFragmentManager, "coreError") + } + + /** + * Handles a core error. + * + * @return true: continue; false: abort + */ + fun onCoreError(error: CoreError?, details: String): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + error("[NativeLibrary] EmulationActivity not present") + return false + } + + val title: String + val message: String + when (error) { + CoreError.ErrorSystemFiles -> { + title = emulationActivity.getString(R.string.system_archive_not_found) + message = emulationActivity.getString( + R.string.system_archive_not_found_message, + details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) } + ) + } + CoreError.ErrorSavestate -> { + title = emulationActivity.getString(R.string.save_load_error) + message = details + } + CoreError.ErrorUnknown -> { + title = emulationActivity.getString(R.string.fatal_error) + message = emulationActivity.getString(R.string.fatal_error_message) + } + else -> { + return true + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) }) + + // Wait for the lock to notify that it is complete. + synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } + + return coreErrorAlertResult + } + + @Keep + @JvmStatic + fun exitEmulationActivity(resultCode: Int) { + val Success = 0 + val ErrorNotInitialized = 1 + val ErrorGetLoader = 2 + val ErrorSystemFiles = 3 + val ErrorSharedFont = 4 + val ErrorVideoCore = 5 + val ErrorUnknown = 6 + val ErrorLoader = 7 + + val captionId: Int + var descriptionId: Int + when (resultCode) { + ErrorVideoCore -> { + captionId = R.string.loader_error_video_core + descriptionId = R.string.loader_error_video_core_description + } + else -> { + captionId = R.string.loader_error_encrypted + descriptionId = R.string.loader_error_encrypted_roms_description + if (!reloadKeys()) { + descriptionId = R.string.loader_error_encrypted_keys_description + } + } + } + + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + warning("[NativeLibrary] EmulationActivity is null, can't exit.") + return + } + + val builder = MaterialAlertDialogBuilder(emulationActivity) + .setTitle(captionId) + .setMessage( + Html.fromHtml( + emulationActivity.getString(descriptionId), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() } + .setOnDismissListener { emulationActivity.finish() } + emulationActivity.runOnUiThread { + val alert = builder.create() + alert.show() + (alert.findViewById<View>(android.R.id.message) as TextView).movementMethod = + LinkMovementMethod.getInstance() + } + } + + fun setEmulationActivity(emulationActivity: EmulationActivity?) { + verbose("[NativeLibrary] Registering EmulationActivity.") + sEmulationActivity = WeakReference(emulationActivity) + } + + fun clearEmulationActivity() { + verbose("[NativeLibrary] Unregistering EmulationActivity.") + sEmulationActivity.clear() + } + + /** + * Logs the Yuzu version, Android version and, CPU. + */ + external fun logDeviceInfo() + + /** + * Submits inline keyboard text. Called on input for buttons that result text. + * @param text Text to submit to the inline software keyboard implementation. + */ + external fun submitInlineKeyboardText(text: String?) + + /** + * Submits inline keyboard input. Used to indicate keys pressed that are not text. + * @param key_code Android Key Code associated with the keyboard input. + */ + external fun submitInlineKeyboardInput(key_code: Int) + + /** + * Button type for use in onTouchEvent + */ + object ButtonType { + const val BUTTON_A = 0 + const val BUTTON_B = 1 + const val BUTTON_X = 2 + const val BUTTON_Y = 3 + const val STICK_L = 4 + const val STICK_R = 5 + const val TRIGGER_L = 6 + const val TRIGGER_R = 7 + const val TRIGGER_ZL = 8 + const val TRIGGER_ZR = 9 + const val BUTTON_PLUS = 10 + const val BUTTON_MINUS = 11 + const val DPAD_LEFT = 12 + const val DPAD_UP = 13 + const val DPAD_RIGHT = 14 + const val DPAD_DOWN = 15 + const val BUTTON_SL = 16 + const val BUTTON_SR = 17 + const val BUTTON_HOME = 18 + const val BUTTON_CAPTURE = 19 + } + + /** + * Stick type for use in onTouchEvent + */ + object StickType { + const val STICK_L = 0 + const val STICK_R = 1 + } + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt new file mode 100644 index 000000000..4c947b786 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.DocumentsTree +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import java.io.File + +fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir + +class YuzuApplication : Application() { + private fun createNotificationChannels() { + val emulationChannel = NotificationChannel( + getString(R.string.emulation_notification_channel_id), + getString(R.string.emulation_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + emulationChannel.description = getString(R.string.emulation_notification_channel_description) + emulationChannel.setSound(null, null) + emulationChannel.vibrationPattern = null + + 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(emulationChannel) + notificationManager.createNotificationChannel(noticeChannel) + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + DirectoryInitialization.start(applicationContext) + GpuDriverHelper.initializeDriverParameters(applicationContext) + NativeLibrary.logDeviceInfo() + + createNotificationChannels(); + } + + companion object { + var documentsTree: DocumentsTree? = null + lateinit var application: YuzuApplication + + val appContext: Context + get() = application.applicationContext + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt new file mode 100644 index 000000000..94d5156cf --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.activities + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.Rect +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.hardware.display.DisplayManager +import android.os.Bundle +import android.view.Display +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 androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.getSystemService +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.window.layout.WindowInfoTracker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel +import org.yuzu.yuzu_emu.fragments.EmulationFragment +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.ControllerMappingHelper +import org.yuzu.yuzu_emu.utils.EmulationMenuSettings +import org.yuzu.yuzu_emu.utils.ForegroundService +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.NfcReader +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable +import org.yuzu.yuzu_emu.utils.ThemeHelper +import kotlin.math.roundToInt + +class EmulationActivity : AppCompatActivity(), SensorEventListener { + private var controllerMappingHelper: ControllerMappingHelper? = null + + var isActivityRecreated = false + private var emulationFragment: EmulationFragment? = null + private lateinit var nfcReader: NfcReader + private lateinit var inputHandler: InputHandler + + private val gyro = FloatArray(3) + private val accel = FloatArray(3) + private var motionTimestamp: Long = 0 + private var flipMotionOrientation: Boolean = false + + private lateinit var game: Game + + private val settingsViewModel: SettingsViewModel by viewModels() + + override fun onDestroy() { + stopForegroundService(this) + super.onDestroy() + } + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + settingsViewModel.settings.loadSettings() + + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + // Get params we were passed + game = intent.parcelable(EXTRA_SELECTED_GAME)!! + isActivityRecreated = false + } else { + isActivityRecreated = true + restoreState(savedInstanceState) + } + controllerMappingHelper = ControllerMappingHelper() + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + setContentView(R.layout.activity_emulation) + window.decorView.setBackgroundColor(getColor(android.R.color.black)) + + // Find or create the EmulationFragment + emulationFragment = + supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment? + if (emulationFragment == null) { + emulationFragment = EmulationFragment.newInstance(game) + supportFragmentManager.beginTransaction() + .add(R.id.frame_emulation_fragment, emulationFragment!!) + .commit() + } + title = game.title + + nfcReader = NfcReader(this) + nfcReader.initialize() + + inputHandler = InputHandler() + inputHandler.initialize() + + lifecycleScope.launch(Dispatchers.Main) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + WindowInfoTracker.getOrCreate(this@EmulationActivity) + .windowLayoutInfo(this@EmulationActivity) + .collect { emulationFragment?.updateCurrentLayout(this@EmulationActivity, it) } + } + } + + // Start a foreground service to prevent the app from getting killed in the background + val startIntent = Intent(this, ForegroundService::class.java) + startForegroundService(startIntent) + } + + 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() + + NativeLibrary.notifyOrientationChange( + EmulationMenuSettings.landscapeScreenLayout, + getAdjustedRotation() + ) + } + + override fun onPause() { + super.onPause() + nfcReader.stopScanning() + stopMotionSensorListener() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + nfcReader.onNewIntent(intent) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelable(EXTRA_SELECTED_GAME, game) + super.onSaveInstanceState(outState) + } + + 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) + } + + 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) + } + + // 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 + NativeLibrary.onGamePadMotionEvent( + NativeLibrary.Player1Device, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + NativeLibrary.onGamePadMotionEvent( + NativeLibrary.ConsoleDevice, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + } + + override fun onAccuracyChanged(sensor: Sensor, i: Int) {} + + private fun getAdjustedRotation():Int { + val rotation = getSystemService<DisplayManager>()!!.getDisplay(Display.DEFAULT_DISPLAY).rotation + val config: Configuration = resources.configuration + + if ((config.screenLayout and Configuration.SCREENLAYOUT_LONG_YES) != 0 || + (config.screenLayout and Configuration.SCREENLAYOUT_LONG_NO) == 0) { + return rotation + } + when (rotation) { + Surface.ROTATION_0 -> return Surface.ROTATION_90 + Surface.ROTATION_90 -> return Surface.ROTATION_0 + Surface.ROTATION_180 -> return Surface.ROTATION_270 + Surface.ROTATION_270 -> return Surface.ROTATION_180 + } + return rotation + } + + private fun restoreState(savedInstanceState: Bundle) { + game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!! + } + + 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 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) + } + + fun stopForegroundService(activity: Activity) { + val startIntent = Intent(activity, ForegroundService::class.java) + startIntent.action = ForegroundService.ACTION_STOP + activity.startForegroundService(startIntent) + } + + 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/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt new file mode 100644 index 000000000..7f9e2e2d4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.CardGameBinding +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder +import org.yuzu.yuzu_emu.model.GamesViewModel + +class GameAdapter(private val activity: AppCompatActivity) : + ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + // Create a new view. + val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.cardGame.setOnClickListener(this) + + // Use that view to create a ViewHolder. + return GameViewHolder(binding) + } + + override fun onBindViewHolder(holder: GameViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + override fun getItemCount(): Int = currentList.size + + /** + * Launches the game that was clicked on. + * + * @param view The card representing the game the user wants to play. + */ + override fun onClick(view: View) { + val holder = view.tag as GameViewHolder + + val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true + if (!gameExists) { + Toast.makeText( + YuzuApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + return + } + + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.edit() + .putLong( + holder.game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + EmulationActivity.launch(activity, holder.game) + } + + inner class GameViewHolder(val binding: CardGameBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var game: Game + + init { + binding.cardGame.tag = this + } + + fun bind(game: Game) { + this.game = game + + binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + activity.lifecycleScope.launch { + val bitmap = decodeGameIcon(game.path) + binding.imageGameScreen.load(bitmap) { + error(R.drawable.default_icon) + } + } + + binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + binding.textGameTitle.postDelayed( + { + binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.textGameTitle.isSelected = true + }, + 3000 + ) + } + } + + private class DiffCallback : DiffUtil.ItemCallback<Game>() { + override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem.gameId == newItem.gameId + } + + override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem == newItem + } + } + + private fun decodeGameIcon(uri: String): Bitmap? { + val data = NativeLibrary.getIcon(uri) + return BitmapFactory.decodeByteArray( + data, + 0, + data.size, + BitmapFactory.Options() + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt new file mode 100644 index 000000000..b719dd539 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding +import org.yuzu.yuzu_emu.model.HomeSetting + +class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List<HomeSetting>) : + RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { + val binding = + CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.root.setOnClickListener(this) + return HomeOptionViewHolder(binding) + } + + override fun getItemCount(): Int { + return options.size + } + + override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) { + holder.bind(options[position]) + } + + override fun onClick(view: View) { + val holder = view.tag as HomeOptionViewHolder + holder.option.onClick.invoke() + } + + inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var option: HomeSetting + + init { + itemView.tag = this + } + + fun bind(option: HomeSetting) { + this.option = option + binding.optionTitle.text = activity.resources.getString(option.titleId) + binding.optionDescription.text = activity.resources.getString(option.descriptionId) + binding.optionIcon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + option.iconId, + activity.theme + ) + ) + + when (option.titleId) { + R.string.get_early_access -> binding.optionLayout.background = + ContextCompat.getDrawable( + binding.optionCard.context, + R.drawable.premium_background + ) + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt new file mode 100644 index 000000000..7006651d0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment +import org.yuzu.yuzu_emu.model.License + +class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List<License>) : + RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { + val binding = + ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.root.setOnClickListener(this) + return LicenseViewHolder(binding) + } + + override fun getItemCount(): Int = licenses.size + + override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) { + holder.bind(licenses[position]) + } + + override fun onClick(view: View) { + val license = (view.tag as LicenseViewHolder).license + LicenseBottomSheetDialogFragment.newInstance(license) + .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) + } + + inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) { + lateinit var license: License + + init { + itemView.tag = this + } + + fun bind(license: License) { + this.license = license + + val context = YuzuApplication.appContext + binding.textSettingName.text = context.getString(license.titleId) + binding.textSettingDescription.text = context.getString(license.descriptionId) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt new file mode 100644 index 000000000..481ddd5a5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import org.yuzu.yuzu_emu.databinding.PageSetupBinding +import org.yuzu.yuzu_emu.model.SetupPage + +class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) : + RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { + val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return SetupPageViewHolder(binding) + } + + override fun getItemCount(): Int = pages.size + + override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) = + holder.bind(pages[position]) + + inner class SetupPageViewHolder(val binding: PageSetupBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var page: SetupPage + + init { + itemView.tag = this + } + + fun bind(page: SetupPage) { + this.page = page + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + page.iconId, + activity.theme + ) + ) + binding.textTitle.text = activity.resources.getString(page.titleId) + binding.textDescription.text = + Html.fromHtml(activity.resources.getString(page.descriptionId), 0) + + binding.buttonAction.apply { + text = activity.resources.getString(page.buttonTextId) + if (page.buttonIconId != 0) { + icon = ResourcesCompat.getDrawable( + activity.resources, + page.buttonIconId, + activity.theme + ) + } + iconGravity = + if (page.leftAlignedIcon) { + MaterialButton.ICON_GRAVITY_START + } else { + MaterialButton.ICON_GRAVITY_END + } + setOnClickListener { + page.buttonAction.invoke() + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt new file mode 100644 index 000000000..82a6712b6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.View +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.annotation.Keep +import androidx.core.view.ViewCompat +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment +import java.io.Serializable + +@Keep +object SoftwareKeyboard { + lateinit var data: KeyboardData + val dataLock = Object() + + private fun executeNormalImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = KeyboardData(SwkbdResult.Cancel.ordinal, "") + val fragment = KeyboardDialogFragment.newInstance(config) + fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) + } + + private fun executeInlineImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + + val overlayView = emulationActivity!!.findViewById<View>(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) + + // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. + val handler = Handler(Looper.myLooper()!!) + val delayMs = 500 + handler.postDelayed(object : Runnable { + override fun run() { + val insets = ViewCompat.getRootWindowInsets(overlayView) + val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) + if (isKeyboardVisible) { + handler.postDelayed(this, delayMs.toLong()) + return + } + + // No longer visible, submit the result. + NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) + } + }, delayMs.toLong()) + } + + @JvmStatic + fun executeNormal(config: KeyboardConfig): KeyboardData { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } + synchronized(dataLock) { + dataLock.wait() + } + return data + } + + @JvmStatic + fun executeInline(config: KeyboardConfig) { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } + } + + // Corresponds to Service::AM::Applets::SwkbdType + enum class SwkbdType { + Normal, + NumberPad, + Qwerty, + Unknown3, + Latin, + SimplifiedChinese, + TraditionalChinese, + Korean + } + + // Corresponds to Service::AM::Applets::SwkbdPasswordMode + enum class SwkbdPasswordMode { + Disabled, + Enabled + } + + // Corresponds to Service::AM::Applets::SwkbdResult + enum class SwkbdResult { + Ok, + Cancel + } + + @Keep + data class KeyboardConfig( + var ok_text: String? = null, + var header_text: String? = null, + var sub_text: String? = null, + var guide_text: String? = null, + var initial_text: String? = null, + var left_optional_symbol_key: Short = 0, + var right_optional_symbol_key: Short = 0, + var max_text_length: Int = 0, + var min_text_length: Int = 0, + var initial_cursor_position: Int = 0, + var type: Int = 0, + var password_mode: Int = 0, + var text_draw_type: Int = 0, + var key_disable_flags: Int = 0, + var use_blur_background: Boolean = false, + var enable_backspace_button: Boolean = false, + var enable_return_button: Boolean = false, + var disable_cancel_button: Boolean = false + ) : Serializable + + // Corresponds to Frontend::KeyboardData + @Keep + data class KeyboardData(var result: Int, var text: String) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt new file mode 100644 index 000000000..607a3d506 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable + +class KeyboardDialogFragment : DialogFragment() { + private lateinit var binding: DialogEditTextBinding + private lateinit var config: KeyboardConfig + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + config = requireArguments().serializable(CONFIG)!! + + // Set up the input + binding.editText.hint = config.initial_text + binding.editText.isSingleLine = !config.enable_return_button + binding.editText.filters = + arrayOf<InputFilter>(InputFilter.LengthFilter(config.max_text_length)) + + // Handle input type + var inputType: Int + when (config.type) { + SoftwareKeyboard.SwkbdType.Normal.ordinal, + SoftwareKeyboard.SwkbdType.Qwerty.ordinal, + SoftwareKeyboard.SwkbdType.Unknown3.ordinal, + SoftwareKeyboard.SwkbdType.Latin.ordinal, + SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, + SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, + SoftwareKeyboard.SwkbdType.Korean.ordinal -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { + inputType = InputType.TYPE_CLASS_NUMBER + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD + } + } + else -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + } + binding.editText.inputType = inputType + + val headerText = + config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } + val okText = + config.ok_text!!.ifEmpty { resources.getString(R.string.submit) } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(headerText) + .setView(binding.root) + .setPositiveButton(okText) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal + SoftwareKeyboard.data.text = binding.editText.text.toString() + } + .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + synchronized(SoftwareKeyboard.dataLock) { + SoftwareKeyboard.dataLock.notifyAll() + } + } + + companion object { + const val TAG = "KeyboardDialogFragment" + const val CONFIG = "keyboard_config" + + fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { + val frag = KeyboardDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100644 index 000000000..3b1559c80 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache + +import androidx.annotation.Keep +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment + +@Keep +object DiskShaderCacheProgress { + val finishLock = Object() + private lateinit var fragment: ShaderProgressDialogFragment + + private fun prepareDialog() { + val emulationActivity = NativeLibrary.sEmulationActivity.get()!! + emulationActivity.runOnUiThread { + fragment = ShaderProgressDialogFragment.newInstance( + emulationActivity.getString(R.string.loading), + emulationActivity.getString(R.string.preparing_shaders) + ) + fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG) + } + synchronized(finishLock) { finishLock.wait() } + } + + @JvmStatic + fun loadProgress(stage: Int, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + ?: error("[DiskShaderCacheProgress] EmulationActivity not present") + + when (LoadCallbackStage.values()[stage]) { + LoadCallbackStage.Prepare -> prepareDialog() + LoadCallbackStage.Build -> fragment.onUpdateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + LoadCallbackStage.Complete -> fragment.dismiss() + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, Build, Complete + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt new file mode 100644 index 000000000..bf6f0366d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class ShaderProgressViewModel : ViewModel() { + private val _progress = MutableLiveData(0) + val progress: LiveData<Int> get() = _progress + + private val _max = MutableLiveData(0) + val max: LiveData<Int> get() = _max + + private val _message = MutableLiveData("") + val message: LiveData<String> get() = _message + + fun setProgress(progress: Int) { + _progress.postValue(progress) + } + + fun setMax(max: Int) { + _max.postValue(max) + } + + fun setMessage(msg: String) { + _message.postValue(msg) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt new file mode 100644 index 000000000..2c68c9ac3 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress +import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel + +class ShaderProgressDialogFragment : DialogFragment() { + private var _binding: DialogProgressBarBinding? = null + private val binding get() = _binding!! + + private lateinit var alertDialog: AlertDialog + + private lateinit var shaderProgressViewModel: ShaderProgressViewModel + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogProgressBarBinding.inflate(layoutInflater) + shaderProgressViewModel = + ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java] + + val title = requireArguments().getString(TITLE) + val message = requireArguments().getString(MESSAGE) + + isCancelable = false + alertDialog = MaterialAlertDialogBuilder(requireActivity()) + .setView(binding.root) + .setTitle(title) + .setMessage(message) + .create() + return alertDialog + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress -> + binding.progressBar.progress = progress + setUpdateText() + } + shaderProgressViewModel.max.observe(viewLifecycleOwner) { max -> + binding.progressBar.max = max + setUpdateText() + } + shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg -> + alertDialog.setMessage(msg) + } + synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun onUpdateProgress(msg: String, progress: Int, max: Int) { + shaderProgressViewModel.setProgress(progress) + shaderProgressViewModel.setMax(max) + shaderProgressViewModel.setMessage(msg) + } + + private fun setUpdateText() { + binding.progressText.text = String.format( + "%d/%d", + shaderProgressViewModel.progress.value, + shaderProgressViewModel.max.value + ) + } + + companion object { + const val TAG = "ProgressDialogFragment" + const val TITLE = "title" + const val MESSAGE = "message" + + fun newInstance(title: String, message: String): ShaderProgressDialogFragment { + val frag = ShaderProgressDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt new file mode 100644 index 000000000..4c3a9ca80 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) + +package org.yuzu.yuzu_emu.features + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.getPublicFilesDir +import java.io.* + +class DocumentProvider : DocumentsProvider() { + private val baseDirectory: File + get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) + + companion object { + private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + + const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user" + const val ROOT_ID: String = "root" + } + + override fun onCreate(): Boolean { + return true + } + + /** + * @return The [File] that corresponds to the document ID supplied by [getDocumentId] + */ + private fun getFile(documentId: String): File { + if (documentId.startsWith(ROOT_ID)) { + val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) + if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found") + return file + } else { + throw FileNotFoundException("'$documentId' is not in any known root") + } + } + + /** + * @return A unique ID for the provided [File] + */ + private fun getDocumentId(file: File): String { + return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" + } + + override fun queryRoots(projection: Array<out String>?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + + cursor.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add(DocumentsContract.Root.COLUMN_SUMMARY, null) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + return includeFile(cursor, documentId, null) + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + return documentId?.startsWith(parentDocumentId!!) ?: false + } + + /** + * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file + */ + private fun File.resolveWithoutConflict(name: String): File { + var file = resolve(name) + if (file.exists()) { + var noConflictId = + 1 // Makes sure two files don't have the same name by adding a number to the end + val extension = name.substringAfterLast('.') + val baseName = name.substringBeforeLast('.') + while (file.exists()) + file = resolve("$baseName (${noConflictId++}).$extension") + } + return file + } + + override fun createDocument( + parentDocumentId: String?, + mimeType: String?, + displayName: String + ): String { + val parentFile = getFile(parentDocumentId!!) + val newFile = parentFile.resolveWithoutConflict(displayName) + + try { + if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { + if (!newFile.mkdir()) + throw IOException("Failed to create directory") + } else { + if (!newFile.createNewFile()) + throw IOException("Failed to create file") + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun deleteDocument(documentId: String?) { + val file = getFile(documentId!!) + if (!file.delete()) + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + + override fun removeDocument(documentId: String, parentDocumentId: String?) { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId) + + if (parent == file || file.parentFile == null || file.parentFile!! == parent) { + if (!file.delete()) + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } else { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun renameDocument(documentId: String?, displayName: String?): String { + if (displayName == null) + throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null") + + val sourceFile = getFile(documentId!!) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") + val destFile = sourceParentFile.resolve(displayName) + + try { + if (!sourceFile.renameTo(destFile)) + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") + } catch (e: Exception) { + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}") + } + + return getDocumentId(destFile) + } + + private fun copyDocument( + sourceDocumentId: String, sourceParentDocumentId: String, + targetParentDocumentId: String? + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") + + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { + val parent = getFile(targetParentDocumentId!!) + val oldFile = getFile(sourceDocumentId) + val newFile = parent.resolveWithoutConflict(oldFile.name) + + try { + if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) + throw IOException("Couldn't create new file") + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + inStream.copyTo(outStream) + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, sourceParentDocumentId: String?, + targetParentDocumentId: String? + ): String { + try { + val newDocumentId = copyDocument( + sourceDocumentId, sourceParentDocumentId!!, + targetParentDocumentId + ) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (e: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") + } + } + + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { + val localDocumentId = documentId ?: file?.let { getDocumentId(it) } + val localFile = file ?: getFile(documentId!!) + + var flags = 0 + if (localFile.isDirectory && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else if (localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name + ) + add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + if (localFile == baseDirectory) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + + return cursor + } + + private fun getTypeForFile(file: File): Any { + return if (file.isDirectory) + DocumentsContract.Document.MIME_TYPE_DIR + else + getTypeForName(file.name) + } + + private fun getTypeForName(name: String): Any { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) + return mime + } + return "application/octect-stream" + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array<out String>?, + sortOrder: String? + ): Cursor { + var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + + val parent = getFile(parentDocumentId!!) + for (file in parent.listFiles()!!) + cursor = includeFile(cursor, null, file) + + return cursor + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = documentId?.let { getFile(it) } + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt new file mode 100644 index 000000000..a6e9833ee --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractBooleanSetting : AbstractSetting { + var boolean: Boolean +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100644 index 000000000..6fe4bc263 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractFloatSetting : AbstractSetting { + var float: Float +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt new file mode 100644 index 000000000..892b7dcfe --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractIntSetting : AbstractSetting { + var int: Int +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt new file mode 100644 index 000000000..258580209 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractSetting { + val key: String? + val section: String? + val isRuntimeEditable: Boolean + val valueAsString: String + val defaultValue: Any +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt new file mode 100644 index 000000000..0d02c5997 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractStringSetting : AbstractSetting { + var string: String +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt new file mode 100644 index 000000000..3dfd66779 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class BooleanSetting( + override val key: String, + override val section: String, + override val defaultValue: Boolean +) : AbstractBooleanSetting { + USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false); + + override var boolean: Boolean = defaultValue + + override val valueAsString: String + get() = boolean.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + USE_CUSTOM_RTC + ) + + fun from(key: String): BooleanSetting? = + BooleanSetting.values().firstOrNull { it.key == key } + + fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt new file mode 100644 index 000000000..e5545a916 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class FloatSetting( + override val key: String, + override val section: String, + override val defaultValue: Float +) : AbstractFloatSetting { + // No float settings currently exist + EMPTY_SETTING("", "", 0f); + + override var float: Float = defaultValue + + override val valueAsString: String + get() = float.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>() + + fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key } + + fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt new file mode 100644 index 000000000..c5722a5a1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class IntSetting( + override val key: String, + override val section: String, + override val defaultValue: Int +) : AbstractIntSetting { + RENDERER_USE_SPEED_LIMIT( + "use_speed_limit", + Settings.SECTION_RENDERER, + 1 + ), + USE_DOCKED_MODE( + "use_docked_mode", + Settings.SECTION_SYSTEM, + 0 + ), + RENDERER_USE_DISK_SHADER_CACHE( + "use_disk_shader_cache", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_FORCE_MAX_CLOCK( + "force_max_clock", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ASYNCHRONOUS_SHADERS( + "use_asynchronous_shaders", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_DEBUG( + "debug", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_SPEED_LIMIT( + "speed_limit", + Settings.SECTION_RENDERER, + 100 + ), + CPU_ACCURACY( + "cpu_accuracy", + Settings.SECTION_CPU, + 0 + ), + REGION_INDEX( + "region_index", + Settings.SECTION_SYSTEM, + -1 + ), + LANGUAGE_INDEX( + "language_index", + Settings.SECTION_SYSTEM, + 1 + ), + RENDERER_BACKEND( + "backend", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ACCURACY( + "gpu_accuracy", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_RESOLUTION( + "resolution_setup", + Settings.SECTION_RENDERER, + 2 + ), + RENDERER_VSYNC( + "use_vsync", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_SCALING_FILTER( + "scaling_filter", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ANTI_ALIASING( + "anti_aliasing", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_ASPECT_RATIO( + "aspect_ratio", + Settings.SECTION_RENDERER, + 0 + ), + AUDIO_VOLUME( + "volume", + Settings.SECTION_AUDIO, + 100 + ); + + override var int: Int = defaultValue + + override val valueAsString: String + get() = int.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + RENDERER_USE_DISK_SHADER_CACHE, + RENDERER_ASYNCHRONOUS_SHADERS, + RENDERER_DEBUG, + RENDERER_BACKEND, + RENDERER_RESOLUTION, + RENDERER_VSYNC + ) + + fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key } + + fun clear() = IntSetting.values().forEach { it.int = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt new file mode 100644 index 000000000..474f598a9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +/** + * A semantically-related group of Settings objects. These Settings are + * internally stored as a HashMap. + */ +class SettingSection(val name: String) { + val settings = HashMap<String, AbstractSetting>() + + /** + * Convenience method; inserts a value directly into the backing HashMap. + * + * @param setting The Setting to be inserted. + */ + fun putSetting(setting: AbstractSetting) { + settings[setting.key!!] = setting + } + + /** + * Convenience method; gets a value directly from the backing HashMap. + * + * @param key Used to retrieve the Setting. + * @return A Setting object (you should probably cast this before using) + */ + fun getSetting(key: String): AbstractSetting? { + return settings[key] + } + + fun mergeSection(settingSection: SettingSection) { + for (setting in settingSection.settings.values) { + putSetting(setting) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt new file mode 100644 index 000000000..8df20b928 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import android.text.TextUtils +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import java.util.* + +class Settings { + private var gameId: String? = null + + var isLoaded = false + + /** + * A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null + * when getting a key not already in the map + */ + class SettingsSectionMap : HashMap<String, SettingSection?>() { + override operator fun get(key: String): SettingSection? { + if (!super.containsKey(key)) { + val section = SettingSection(key) + super.put(key, section) + return section + } + return super.get(key) + } + } + + var sections: HashMap<String, SettingSection?> = SettingsSectionMap() + + fun getSection(sectionName: String): SettingSection? { + return sections[sectionName] + } + + val isEmpty: Boolean + get() = sections.isEmpty() + + fun loadSettings(view: SettingsActivityView? = null) { + sections = SettingsSectionMap() + loadYuzuSettings(view) + if (!TextUtils.isEmpty(gameId)) { + loadCustomGameSettings(gameId!!, view) + } + isLoaded = true + } + + private fun loadYuzuSettings(view: SettingsActivityView?) { + for ((fileName) in configFileSectionsMap) { + sections.putAll(SettingsFile.readFile(fileName, view)) + } + } + + private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView?) { + // Custom game settings + mergeSections(SettingsFile.readCustomGameSettings(gameId, view)) + } + + private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) { + for ((key, updatedSection) in updatedSections) { + if (sections.containsKey(key)) { + val originalSection = sections[key] + originalSection!!.mergeSection(updatedSection!!) + } else { + sections[key] = updatedSection + } + } + } + + fun loadSettings(gameId: String, view: SettingsActivityView) { + this.gameId = gameId + loadSettings(view) + } + + fun saveSettings(view: SettingsActivityView) { + if (TextUtils.isEmpty(gameId)) { + view.showToastMessage( + YuzuApplication.appContext.getString(R.string.ini_saved), + false + ) + + for ((fileName, sectionNames) in configFileSectionsMap) { + val iniSections = TreeMap<String, SettingSection>() + for (section in sectionNames) { + iniSections[section] = sections[section]!! + } + + SettingsFile.saveFile(fileName, iniSections, view) + } + } else { + // Custom game settings + view.showToastMessage( + YuzuApplication.appContext.getString(R.string.gameid_saved, gameId), + false + ) + + SettingsFile.saveCustomGameSettings(gameId, sections) + } + } + + companion object { + const val SECTION_GENERAL = "General" + const val SECTION_SYSTEM = "System" + const val SECTION_RENDERER = "Renderer" + const val SECTION_AUDIO = "Audio" + const val SECTION_CPU = "Cpu" + const val SECTION_THEME = "Theme" + const val SECTION_DEBUG = "Debug" + + const val PREF_OVERLAY_INIT = "OverlayInit" + const val PREF_CONTROL_SCALE = "controlScale" + const val PREF_CONTROL_OPACITY = "controlOpacity" + const val PREF_TOUCH_ENABLED = "isTouchEnabled" + const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0" + const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1" + const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2" + const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3" + const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4" + const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5" + const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6" + const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7" + const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8" + const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9" + const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10" + const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11" + const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12" + const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13" + const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14" + + 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_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout" + const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" + const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" + + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" + const val PREF_THEME = "Theme" + const val PREF_THEME_MODE = "ThemeMode" + const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" + + private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap() + + init { + configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] = + listOf( + SECTION_GENERAL, + SECTION_SYSTEM, + SECTION_RENDERER, + SECTION_AUDIO, + SECTION_CPU + ) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt new file mode 100644 index 000000000..bd9233d62 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import androidx.lifecycle.ViewModel + +class SettingsViewModel : ViewModel() { + val settings = Settings() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt new file mode 100644 index 000000000..63f95690c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class StringSetting( + override val key: String, + override val section: String, + override val defaultValue: String +) : AbstractStringSetting { + CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0"); + + override var string: String = defaultValue + + override val valueAsString: String + get() = string + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + CUSTOM_RTC + ) + + fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key } + + fun clear() = StringSetting.values().forEach { it.string = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100644 index 000000000..bc0bf7788 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting + +class DateTimeSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val key: String? = null, + private val defaultValue: String? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_DATETIME_SETTING + + val value: String + get() = if (setting != null) { + val setting = setting as AbstractStringSetting + setting.string + } else { + defaultValue!! + } + + fun setSelectedValue(datetime: String): AbstractStringSetting { + val stringSetting = setting as AbstractStringSetting + stringSetting.string = datetime + return stringSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt new file mode 100644 index 000000000..0f8edbfb0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class HeaderSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_HEADER +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt new file mode 100644 index 000000000..caaab50d8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +class RunnableSetting( + titleId: Int, + descriptionId: Int, + val isRuntimeRunnable: Boolean, + val runnable: () -> Unit +) : SettingsItem(null, titleId, descriptionId) { + override val type = TYPE_RUNNABLE +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt new file mode 100644 index 000000000..07520849e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a [AbstractSetting] object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +abstract class SettingsItem( + var setting: AbstractSetting?, + val nameId: Int, + val descriptionId: Int +) { + abstract val type: Int + + val isEditable: Boolean + get() { + if (!NativeLibrary.isRunning()) return true + return setting?.isRuntimeEditable ?: false + } + + companion object { + const val TYPE_HEADER = 0 + const val TYPE_SWITCH = 1 + const val TYPE_SINGLE_CHOICE = 2 + const val TYPE_SLIDER = 3 + const val TYPE_SUBMENU = 4 + const val TYPE_STRING_SINGLE_CHOICE = 5 + const val TYPE_DATETIME_SETTING = 6 + const val TYPE_RUNNABLE = 7 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100644 index 000000000..9eac9904e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting + +class SingleChoiceSetting( + setting: AbstractIntSetting?, + titleId: Int, + descriptionId: Int, + val choicesId: Int, + val valuesId: Int, + val key: String? = null, + val defaultValue: Int? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SINGLE_CHOICE + + val selectedValue: Int + get() = if (setting != null) { + val setting = setting as AbstractIntSetting + setting.int + } else { + defaultValue!! + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Int): AbstractIntSetting { + val intSetting = setting as AbstractIntSetting + intSetting.int = selection + return intSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt new file mode 100644 index 000000000..842648ce4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.utils.Log +import kotlin.math.roundToInt + +class SliderSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val min: Int, + val max: Int, + val units: String, + val key: String? = null, + val defaultValue: Int? = null, +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SLIDER + + val selectedValue: Int + get() { + val setting = setting ?: return defaultValue!! + return when (setting) { + is AbstractIntSetting -> setting.int + is AbstractFloatSetting -> setting.float.roundToInt() + else -> { + Log.error("[SliderSetting] Error casting setting type.") + -1 + } + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Int): AbstractIntSetting { + val intSetting = setting as AbstractIntSetting + intSetting.int = selection + return intSetting + } + + /** + * Write a value to the backing float. If that float was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the float. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Float): AbstractFloatSetting { + val floatSetting = setting as AbstractFloatSetting + floatSetting.float = selection + return floatSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt new file mode 100644 index 000000000..9e9b00d10 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting +import org.yuzu.yuzu_emu.features.settings.model.StringSetting + +class StringSingleChoiceSetting( + val key: String? = null, + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val choicesId: Array<String>, + private val valuesId: Array<String>?, + private val defaultValue: String? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_STRING_SINGLE_CHOICE + + fun getValueAt(index: Int): String? { + if (valuesId == null) return null + return if (index >= 0 && index < valuesId.size) { + valuesId[index] + } else "" + } + + val selectedValue: String + get() = if (setting != null) { + val setting = setting as AbstractStringSetting + setting.string + } else { + defaultValue!! + } + val selectValueIndex: Int + get() { + val selectedValue = selectedValue + for (i in valuesId!!.indices) { + if (valuesId[i] == selectedValue) { + return i + } + } + return -1 + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: String): AbstractStringSetting { + val stringSetting = setting as AbstractStringSetting + stringSetting.string = selection + return stringSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt new file mode 100644 index 000000000..a3ef59c2f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SubmenuSetting( + titleId: Int, + descriptionId: Int, + val menuKey: String +) : SettingsItem(null, titleId, descriptionId) { + override val type = TYPE_SUBMENU +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt new file mode 100644 index 000000000..90b198718 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SwitchSetting( + setting: AbstractSetting, + titleId: Int, + descriptionId: Int, + val key: String? = null, + val defaultValue: Any? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SWITCH + + val isChecked: Boolean + get() { + if (setting == null) { + return defaultValue as Boolean + } + + // Try integer setting + try { + val setting = setting as AbstractIntSetting + return setting.int == 1 + } catch (_: ClassCastException) { + } + + // Try boolean setting + try { + val setting = setting as AbstractBooleanSetting + return setting.boolean + } catch (_: ClassCastException) { + } + return defaultValue as Boolean + } + + /** + * Write a value to the backing boolean. If that boolean was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param checked Pretty self explanatory. + * @return the existing setting with the new value applied. + */ + fun setChecked(checked: Boolean): AbstractSetting { + // Try integer setting + try { + val setting = setting as AbstractIntSetting + setting.int = if (checked) 1 else 0 + return setting + } catch (_: ClassCastException) { + } + + // Try boolean setting + val setting = setting as AbstractBooleanSetting + setting.boolean = checked + return setting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt new file mode 100644 index 000000000..72e2cce2a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.View +import android.widget.Toast +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 android.view.ViewGroup.MarginLayoutParams +import androidx.activity.OnBackPressedCallback +import androidx.core.view.updatePadding +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.utils.* +import java.io.IOException + +class SettingsActivity : AppCompatActivity(), SettingsActivityView { + private val presenter = SettingsActivityPresenter(this) + + private lateinit var binding: ActivitySettingsBinding + + private val settingsViewModel: SettingsViewModel by viewModels() + + override val settings: Settings get() = settingsViewModel.settings + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + val launcher = intent + val gameID = launcher.getStringExtra(ARG_GAME_ID) + val menuTag = launcher.getStringExtra(ARG_MENU_TAG) + presenter.onCreate(savedInstanceState, menuTag!!, gameID!!) + + // Show "Back" button in the action bar for navigation + setSupportActionBar(binding.toolbarSettings) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + 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 + ) + ) + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + }) + + setInsets() + } + + override fun onSupportNavigateUp(): Boolean { + navigateBack() + return true + } + + private fun navigateBack() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_settings, menu) + return true + } + + override fun onSaveInstanceState(outState: Bundle) { + // Critical: If super method is not called, rotations will be busted. + super.onSaveInstanceState(outState) + presenter.saveState(outState) + } + + override fun onStart() { + super.onStart() + presenter.onStart() + } + + /** + * If this is called, the user has left the settings screen (potentially through the + * home button) and will expect their changes to be persisted. So we kick off an + * IntentService which will do so on a background thread. + */ + override fun onStop() { + super.onStop() + presenter.onStop(isFinishing) + } + + override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) { + if (!addToStack && settingsFragment != null) { + return + } + + val transaction = supportFragmentManager.beginTransaction() + if (addToStack) { + if (areSystemAnimationsEnabled()) { + transaction.setCustomAnimations( + R.anim.anim_settings_fragment_in, + R.anim.anim_settings_fragment_out, + 0, + R.anim.anim_pop_settings_fragment_out + ) + } + transaction.addToBackStack(null) + } + transaction.replace( + R.id.frame_content, + SettingsFragment.newInstance(menuTag, gameId), + FRAGMENT_TAG + ) + transaction.commit() + } + + private fun areSystemAnimationsEnabled(): Boolean { + val duration = android.provider.Settings.Global.getFloat( + contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f + ) + val transition = android.provider.Settings.Global.getFloat( + contentResolver, + android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f + ) + return duration != 0f && transition != 0f + } + + override fun onSettingsFileLoaded() { + val fragment: SettingsFragmentView? = settingsFragment + fragment?.loadSettingsList() + } + + override fun onSettingsFileNotFound() { + val fragment: SettingsFragmentView? = settingsFragment + fragment?.loadSettingsList() + } + + override fun showToastMessage(message: String, is_long: Boolean) { + Toast.makeText( + this, + message, + if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + ).show() + } + + override fun onSettingChanged() { + presenter.onSettingChanged() + } + + fun onSettingsReset() { + // Prevents saving to a non-existent settings file + presenter.onSettingsReset() + + // Reset the static memory representation of each setting + BooleanSetting.clear() + FloatSetting.clear() + IntSetting.clear() + StringSetting.clear() + + // Delete settings file because the user may have changed values that do not exist in the UI + val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + + showToastMessage(getString(R.string.settings_reset), true) + finish() + } + + fun setToolbarTitle(title: String) { + binding.toolbarSettingsLayout.title = title + } + + private val settingsFragment: SettingsFragment? + get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment? + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + view.updatePadding( + left = barInsets.left + cutoutInsets.left, + right = barInsets.right + cutoutInsets.right + ) + + val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left + mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right + binding.appbarSettings.layoutParams = mlpAppBar + + val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpShade + + windowInsets + } + } + + companion object { + private const val ARG_MENU_TAG = "menu_tag" + private const val ARG_GAME_ID = "game_id" + private const val FRAGMENT_TAG = "settings" + + fun launch(context: Context, menuTag: String?, gameId: String?) { + val settings = Intent(context, SettingsActivity::class.java) + settings.putExtra(ARG_MENU_TAG, menuTag) + settings.putExtra(ARG_GAME_ID, gameId) + context.startActivity(settings) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt new file mode 100644 index 000000000..4361d95fb --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.text.TextUtils +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.Log +import java.io.File + +class SettingsActivityPresenter(private val activityView: SettingsActivityView) { + val settings: Settings get() = activityView.settings + + private var shouldSave = false + private lateinit var menuTag: String + private lateinit var gameId: String + + fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) { + this.menuTag = menuTag + this.gameId = gameId + if (savedInstanceState != null) { + shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE) + } + } + + fun onStart() { + prepareDirectoriesIfNeeded() + } + + private fun loadSettingsUI() { + if (!settings.isLoaded) { + if (!TextUtils.isEmpty(gameId)) { + settings.loadSettings(gameId, activityView) + } else { + settings.loadSettings(activityView) + } + } + activityView.showSettingsFragment(menuTag, false, gameId) + activityView.onSettingsFileLoaded() + } + + private fun prepareDirectoriesIfNeeded() { + val configFile = + File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini") + if (!configFile.exists()) { + Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini") + Log.error("yuzu config file could not be found!") + } + + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start(activityView as Context) + } + loadSettingsUI() + } + + fun onStop(finishing: Boolean) { + if (finishing && shouldSave) { + Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...") + settings.saveSettings(activityView) + } + NativeLibrary.reloadSettings() + } + + fun onSettingChanged() { + shouldSave = true + } + + fun onSettingsReset() { + shouldSave = false + } + + fun saveState(outState: Bundle) { + outState.putBoolean(KEY_SHOULD_SAVE, shouldSave) + } + + companion object { + private const val KEY_SHOULD_SAVE = "should_save" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt new file mode 100644 index 000000000..c186fc388 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import org.yuzu.yuzu_emu.features.settings.model.Settings + +/** + * Abstraction for the Activity that manages SettingsFragments. + */ +interface SettingsActivityView { + /** + * Show a new SettingsFragment. + * + * @param menuTag Identifier for the settings group that should be displayed. + * @param addToStack Whether or not this fragment should replace a previous one. + */ + fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) + + /** + * Called by a contained Fragment to get access to the Setting HashMap + * loaded from disk, so that each Fragment doesn't need to perform its own + * read operation. + * + * @return A HashMap of Settings. + */ + val settings: Settings + + /** + * Called when a load operation completes. + */ + fun onSettingsFileLoaded() + + /** + * Called when a load operation fails. + */ + fun onSettingsFileNotFound() + + /** + * Display a popup text message on screen. + * + * @param message The contents of the onscreen message. + * @param is_long Whether this should be a long Toast or short one. + */ + fun showToastMessage(message: String, is_long: Boolean) + + /** + * End the activity. + */ + fun finish() + + /** + * Called by a containing Fragment to tell the Activity that a setting was changed; + * unless this has been called, the Activity will not save to disk. + */ + fun onSettingChanged() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt new file mode 100644 index 000000000..1eb4899fc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.content.DialogInterface +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.setFragmentResultListener +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogSliderBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* + +class SettingsAdapter( + private val fragmentView: SettingsFragmentView, + private val context: Context +) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener { + private var settings: ArrayList<SettingsItem>? = null + private var clickedItem: SettingsItem? = null + private var clickedPosition: Int + private var dialog: AlertDialog? = null + private var sliderProgress = 0 + private var textSliderValue: TextView? = null + + private var defaultCancelListener = + DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } + + init { + clickedPosition = -1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + SettingsItem.TYPE_HEADER -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SWITCH -> { + SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SLIDER -> { + SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SUBMENU -> { + SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_DATETIME_SETTING -> { + DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_RUNNABLE -> { + RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + else -> { + // TODO: Create an error view since we can't return null now + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + } + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private fun getItem(position: Int): SettingsItem { + return settings!![position] + } + + override fun getItemCount(): Int { + return if (settings != null) { + settings!!.size + } else { + 0 + } + } + + override fun getItemViewType(position: Int): Int { + return getItem(position).type + } + + fun setSettingsList(settings: ArrayList<SettingsItem>?) { + this.settings = settings + notifyDataSetChanged() + } + + fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) { + val setting = item.setChecked(checked) + fragmentView.putSetting(setting) + fragmentView.onSettingChanged() + } + + private fun onSingleChoiceClick(item: SingleChoiceSetting) { + clickedItem = item + val value = getSelectionForSingleChoiceValue(item) + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setSingleChoiceItems(item.choicesId, value, this) + .show() + } + + fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { + clickedPosition = position + onSingleChoiceClick(item) + } + + private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) { + clickedItem = item + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setSingleChoiceItems(item.choicesId, item.selectValueIndex, this) + .show() + } + + fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { + clickedPosition = position + onStringSingleChoiceClick(item) + } + + fun onDateTimeClick(item: DateTimeSetting, position: Int) { + clickedItem = item + clickedPosition = position + val storedTime = java.lang.Long.decode(item.value) * 1000 + + // Helper to extract hour and minute from epoch time + val calendar: Calendar = Calendar.getInstance() + calendar.timeInMillis = storedTime + calendar.timeZone = TimeZone.getTimeZone("UTC") + + var timeFormat: Int = TimeFormat.CLOCK_12H + if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) { + timeFormat = TimeFormat.CLOCK_24H + } + + val datePicker: MaterialDatePicker<Long> = MaterialDatePicker.Builder.datePicker() + .setSelection(storedTime) + .setTitleText(R.string.select_rtc_date) + .build() + val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() + .setTimeFormat(timeFormat) + .setHour(calendar.get(Calendar.HOUR_OF_DAY)) + .setMinute(calendar.get(Calendar.MINUTE)) + .setTitleText(R.string.select_rtc_time) + .build() + + datePicker.addOnPositiveButtonClickListener { + timePicker.show( + (fragmentView.activityView as AppCompatActivity).supportFragmentManager, + "TimePicker" + ) + } + timePicker.addOnPositiveButtonClickListener { + var epochTime: Long = datePicker.selection!! / 1000 + epochTime += timePicker.hour.toLong() * 60 * 60 + epochTime += timePicker.minute.toLong() * 60 + val rtcString = epochTime.toString() + if (item.value != rtcString) { + fragmentView.onSettingChanged() + } + notifyItemChanged(clickedPosition) + val setting = item.setSelectedValue(rtcString) + fragmentView.putSetting(setting) + clickedItem = null + } + datePicker.show( + (fragmentView.activityView as AppCompatActivity).supportFragmentManager, + "DatePicker" + ) + } + + fun onSliderClick(item: SliderSetting, position: Int) { + clickedItem = item + clickedPosition = position + sliderProgress = item.selectedValue + + val inflater = LayoutInflater.from(context) + val sliderBinding = DialogSliderBinding.inflate(inflater) + + textSliderValue = sliderBinding.textValue + textSliderValue!!.text = sliderProgress.toString() + sliderBinding.textUnits.text = item.units + + sliderBinding.slider.apply { + valueFrom = item.min.toFloat() + valueTo = item.max.toFloat() + value = sliderProgress.toFloat() + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + sliderProgress = value.toInt() + textSliderValue!!.text = sliderProgress.toString() + } + } + + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setView(sliderBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int -> + sliderBinding.slider.value = item.defaultValue!!.toFloat() + onClick(dialog, which) + } + .show() + } + + fun onSubmenuClick(item: SubmenuSetting) { + fragmentView.loadSubMenu(item.menuKey) + } + + override fun onClick(dialog: DialogInterface, which: Int) { + when (clickedItem) { + is SingleChoiceSetting -> { + val scSetting = clickedItem as SingleChoiceSetting + val value = getValueForSingleChoiceSelection(scSetting, which) + if (scSetting.selectedValue != value) { + fragmentView.onSettingChanged() + } + + // Get the backing Setting, which may be null (if for example it was missing from the file) + val setting = scSetting.setSelectedValue(value) + fragmentView.putSetting(setting) + closeDialog() + } + + is StringSingleChoiceSetting -> { + val scSetting = clickedItem as StringSingleChoiceSetting + val value = scSetting.getValueAt(which) + if (scSetting.selectedValue != value) fragmentView.onSettingChanged() + val setting = scSetting.setSelectedValue(value!!) + fragmentView.putSetting(setting) + closeDialog() + } + + is SliderSetting -> { + val sliderSetting = clickedItem as SliderSetting + if (sliderSetting.selectedValue != sliderProgress) { + fragmentView.onSettingChanged() + } + if (sliderSetting.setting is FloatSetting) { + val value = sliderProgress.toFloat() + val setting = sliderSetting.setSelectedValue(value) + fragmentView.putSetting(setting) + } else { + val setting = sliderSetting.setSelectedValue(sliderProgress) + fragmentView.putSetting(setting) + } + closeDialog() + } + } + clickedItem = null + sliderProgress = -1 + } + + fun onLongClick(setting: AbstractSetting, position: Int): Boolean { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.reset_setting_confirmation) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int -> + when (setting) { + is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean + is AbstractFloatSetting -> setting.float = setting.defaultValue as Float + is AbstractIntSetting -> setting.int = setting.defaultValue as Int + is AbstractStringSetting -> setting.string = setting.defaultValue as String + } + notifyItemChanged(position) + fragmentView.onSettingChanged() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + + return true + } + + fun closeDialog() { + if (dialog != null) { + if (clickedPosition != -1) { + notifyItemChanged(clickedPosition) + clickedPosition = -1 + } + dialog!!.dismiss() + dialog = null + } + } + + private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { + val valuesId = item.valuesId + return if (valuesId > 0) { + val valuesArray = context.resources.getIntArray(valuesId) + valuesArray[which] + } else { + which + } + } + + private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { + val value = item.selectedValue + val valuesId = item.valuesId + if (valuesId > 0) { + val valuesArray = context.resources.getIntArray(valuesId) + for (index in valuesArray.indices) { + val current = valuesArray[index] + if (current == value) { + return index + } + } + } else { + return value + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt new file mode 100644 index 000000000..867147950 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +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.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem + +class SettingsFragment : Fragment(), SettingsFragmentView { + override var activityView: SettingsActivityView? = null + + private val fragmentPresenter = SettingsFragmentPresenter(this) + private var settingsAdapter: SettingsAdapter? = null + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + override fun onAttach(context: Context) { + super.onAttach(context) + activityView = requireActivity() as SettingsActivityView + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG) + val gameId = requireArguments().getString(ARGUMENT_GAME_ID) + fragmentPresenter.onCreate(menuTag!!, gameId!!) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + settingsAdapter = SettingsAdapter(this, requireActivity()) + val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) + dividerDecoration.isLastItemDecorated = false + binding.listSettings.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(activity) + addItemDecoration(dividerDecoration) + } + fragmentPresenter.onViewCreated() + + setInsets() + } + + override fun onDetach() { + super.onDetach() + activityView = null + if (settingsAdapter != null) { + settingsAdapter!!.closeDialog() + } + } + + override fun showSettingsList(settingsList: ArrayList<SettingsItem>) { + settingsAdapter!!.setSettingsList(settingsList) + } + + override fun loadSettingsList() { + fragmentPresenter.loadSettingsList() + } + + override fun loadSubMenu(menuKey: String) { + activityView!!.showSettingsFragment( + menuKey, + true, + requireArguments().getString(ARGUMENT_GAME_ID)!! + ) + } + + override fun showToastMessage(message: String?, is_long: Boolean) { + activityView!!.showToastMessage(message!!, is_long) + } + + override fun putSetting(setting: AbstractSetting) { + fragmentPresenter.putSetting(setting) + } + + override fun onSettingChanged() { + activityView!!.onSettingChanged() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = insets.bottom) + windowInsets + } + } + + companion object { + private const val ARGUMENT_MENU_TAG = "menu_tag" + private const val ARGUMENT_GAME_ID = "game_id" + + fun newInstance(menuTag: String?, gameId: String?): Fragment { + val fragment = SettingsFragment() + val arguments = Bundle() + arguments.putString(ARGUMENT_MENU_TAG, menuTag) + arguments.putString(ARGUMENT_GAME_ID, gameId) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100644 index 000000000..061046b2e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -0,0 +1,465 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.SharedPreferences +import android.os.Build +import android.text.TextUtils +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment +import org.yuzu.yuzu_emu.utils.ThemeHelper + +class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) { + private var menuTag: String? = null + private lateinit var gameId: String + private var settingsList: ArrayList<SettingsItem>? = null + + private val settingsActivity get() = fragmentView.activityView as SettingsActivity + private val settings get() = fragmentView.activityView!!.settings + + private lateinit var preferences: SharedPreferences + + fun onCreate(menuTag: String, gameId: String) { + this.gameId = gameId + this.menuTag = menuTag + } + + fun onViewCreated() { + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + loadSettingsList() + } + + fun putSetting(setting: AbstractSetting) { + if (setting.section == null) { + return + } + + val section = settings.getSection(setting.section!!)!! + if (section.getSetting(setting.key!!) == null) { + section.putSetting(setting) + } + } + + fun loadSettingsList() { + if (!TextUtils.isEmpty(gameId)) { + settingsActivity.setToolbarTitle("Game Settings: $gameId") + } + val sl = ArrayList<SettingsItem>() + if (menuTag == null) { + return + } + when (menuTag) { + SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl) + Settings.SECTION_GENERAL -> addGeneralSettings(sl) + Settings.SECTION_SYSTEM -> addSystemSettings(sl) + Settings.SECTION_RENDERER -> addGraphicsSettings(sl) + Settings.SECTION_AUDIO -> addAudioSettings(sl) + Settings.SECTION_THEME -> addThemeSettings(sl) + Settings.SECTION_DEBUG -> addDebugSettings(sl) + else -> { + fragmentView.showToastMessage("Unimplemented menu", false) + return + } + } + settingsList = sl + fragmentView.showSettingsList(settingsList!!) + } + + private fun addConfigSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.advanced_settings)) + sl.apply { + add( + SubmenuSetting( + R.string.preferences_general, + 0, + Settings.SECTION_GENERAL + ) + ) + add( + SubmenuSetting( + R.string.preferences_system, + 0, + Settings.SECTION_SYSTEM + ) + ) + add( + SubmenuSetting( + R.string.preferences_graphics, + 0, + Settings.SECTION_RENDERER + ) + ) + add( + SubmenuSetting( + R.string.preferences_audio, + 0, + Settings.SECTION_AUDIO + ) + ) + add( + SubmenuSetting( + R.string.preferences_debug, + 0, + Settings.SECTION_DEBUG + ) + ) + add( + RunnableSetting( + R.string.reset_to_default, + 0, + false + ) { + ResetSettingsDialogFragment().show( + settingsActivity.supportFragmentManager, + ResetSettingsDialogFragment.TAG + ) + } + ) + } + } + + private fun addGeneralSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general)) + sl.apply { + add( + SwitchSetting( + IntSetting.RENDERER_USE_SPEED_LIMIT, + R.string.frame_limit_enable, + R.string.frame_limit_enable_description, + IntSetting.RENDERER_USE_SPEED_LIMIT.key, + IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue + ) + ) + add( + SliderSetting( + IntSetting.RENDERER_SPEED_LIMIT, + R.string.frame_limit_slider, + R.string.frame_limit_slider_description, + 1, + 200, + "%", + IntSetting.RENDERER_SPEED_LIMIT.key, + IntSetting.RENDERER_SPEED_LIMIT.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.CPU_ACCURACY, + R.string.cpu_accuracy, + 0, + R.array.cpuAccuracyNames, + R.array.cpuAccuracyValues, + IntSetting.CPU_ACCURACY.key, + IntSetting.CPU_ACCURACY.defaultValue + ) + ) + } + } + + private fun addSystemSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system)) + sl.apply { + add( + SwitchSetting( + IntSetting.USE_DOCKED_MODE, + R.string.use_docked_mode, + R.string.use_docked_mode_description, + IntSetting.USE_DOCKED_MODE.key, + IntSetting.USE_DOCKED_MODE.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.REGION_INDEX, + R.string.emulated_region, + 0, + R.array.regionNames, + R.array.regionValues, + IntSetting.REGION_INDEX.key, + IntSetting.REGION_INDEX.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.LANGUAGE_INDEX, + R.string.emulated_language, + 0, + R.array.languageNames, + R.array.languageValues, + IntSetting.LANGUAGE_INDEX.key, + IntSetting.LANGUAGE_INDEX.defaultValue + ) + ) + add( + SwitchSetting( + BooleanSetting.USE_CUSTOM_RTC, + R.string.use_custom_rtc, + R.string.use_custom_rtc_description, + BooleanSetting.USE_CUSTOM_RTC.key, + BooleanSetting.USE_CUSTOM_RTC.defaultValue + ) + ) + add( + DateTimeSetting( + StringSetting.CUSTOM_RTC, + R.string.set_custom_rtc, + 0, + StringSetting.CUSTOM_RTC.key, + StringSetting.CUSTOM_RTC.defaultValue + ) + ) + } + } + + private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) + sl.apply { + + add( + SingleChoiceSetting( + IntSetting.RENDERER_ACCURACY, + R.string.renderer_accuracy, + 0, + R.array.rendererAccuracyNames, + R.array.rendererAccuracyValues, + IntSetting.RENDERER_ACCURACY.key, + IntSetting.RENDERER_ACCURACY.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_RESOLUTION, + R.string.renderer_resolution, + 0, + R.array.rendererResolutionNames, + R.array.rendererResolutionValues, + IntSetting.RENDERER_RESOLUTION.key, + IntSetting.RENDERER_RESOLUTION.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_VSYNC, + R.string.renderer_vsync, + 0, + R.array.rendererVSyncNames, + R.array.rendererVSyncValues, + IntSetting.RENDERER_VSYNC.key, + IntSetting.RENDERER_VSYNC.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_SCALING_FILTER, + R.string.renderer_scaling_filter, + 0, + R.array.rendererScalingFilterNames, + R.array.rendererScalingFilterValues, + IntSetting.RENDERER_SCALING_FILTER.key, + IntSetting.RENDERER_SCALING_FILTER.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_ANTI_ALIASING, + R.string.renderer_anti_aliasing, + 0, + R.array.rendererAntiAliasingNames, + R.array.rendererAntiAliasingValues, + IntSetting.RENDERER_ANTI_ALIASING.key, + IntSetting.RENDERER_ANTI_ALIASING.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_ASPECT_RATIO, + R.string.renderer_aspect_ratio, + 0, + R.array.rendererAspectRatioNames, + R.array.rendererAspectRatioValues, + IntSetting.RENDERER_ASPECT_RATIO.key, + IntSetting.RENDERER_ASPECT_RATIO.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_USE_DISK_SHADER_CACHE, + R.string.use_disk_shader_cache, + R.string.use_disk_shader_cache_description, + IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key, + IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_FORCE_MAX_CLOCK, + R.string.renderer_force_max_clock, + R.string.renderer_force_max_clock_description, + IntSetting.RENDERER_FORCE_MAX_CLOCK.key, + IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS, + R.string.renderer_asynchronous_shaders, + R.string.renderer_asynchronous_shaders_description, + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key, + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue + ) + ) + } + } + + private fun addAudioSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio)) + sl.add( + SliderSetting( + IntSetting.AUDIO_VOLUME, + R.string.audio_volume, + R.string.audio_volume_description, + 0, + 100, + "%", + IntSetting.AUDIO_VOLUME.key, + IntSetting.AUDIO_VOLUME.defaultValue + ) + ) + } + + private fun addThemeSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme)) + sl.apply { + val theme: AbstractIntSetting = object : AbstractIntSetting { + override var int: Int + get() = preferences.getInt(Settings.PREF_THEME, 0) + set(value) { + preferences.edit() + .putInt(Settings.PREF_THEME, value) + .apply() + settingsActivity.recreate() + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getInt(Settings.PREF_THEME, 0).toString() + override val defaultValue: Any = 0 + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add( + SingleChoiceSetting( + theme, + R.string.change_app_theme, + 0, + R.array.themeEntriesA12, + R.array.themeValuesA12 + ) + ) + } else { + add( + SingleChoiceSetting( + theme, + R.string.change_app_theme, + 0, + R.array.themeEntries, + R.array.themeValues + ) + ) + } + + val themeMode: AbstractIntSetting = object : AbstractIntSetting { + override var int: Int + get() = preferences.getInt(Settings.PREF_THEME_MODE, -1) + set(value) { + preferences.edit() + .putInt(Settings.PREF_THEME_MODE, value) + .apply() + ThemeHelper.setThemeMode(settingsActivity) + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString() + override val defaultValue: Any = -1 + } + + add( + SingleChoiceSetting( + themeMode, + R.string.change_theme_mode, + 0, + R.array.themeModeEntries, + R.array.themeModeValues + ) + ) + + val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { + override var boolean: Boolean + get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value) + .apply() + settingsActivity.recreate() + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + .toString() + override val defaultValue: Any = false + } + + add( + SwitchSetting( + blackBackgrounds, + R.string.use_black_backgrounds, + R.string.use_black_backgrounds_description + ) + ) + } + } + + private fun addDebugSettings(sl: ArrayList<SettingsItem>) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_debug)) + sl.apply { + add( + SingleChoiceSetting( + IntSetting.RENDERER_BACKEND, + R.string.renderer_api, + 0, + R.array.rendererApiNames, + R.array.rendererApiValues, + IntSetting.RENDERER_BACKEND.key, + IntSetting.RENDERER_BACKEND.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_DEBUG, + R.string.renderer_debug, + R.string.renderer_debug_description, + IntSetting.RENDERER_DEBUG.key, + IntSetting.RENDERER_DEBUG.defaultValue + ) + ) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt new file mode 100644 index 000000000..1ebe35eaa --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem + +/** + * Abstraction for a screen showing a list of settings. Instances of + * this type of view will each display a layer of the setting hierarchy. + */ +interface SettingsFragmentView { + /** + * Pass an ArrayList to the View so that it can be displayed on screen. + * + * @param settingsList The result of converting the HashMap to an ArrayList + */ + fun showSettingsList(settingsList: ArrayList<SettingsItem>) + + /** + * Instructs the Fragment to load the settings screen. + */ + fun loadSettingsList() + + /** + * @return The Fragment's containing activity. + */ + val activityView: SettingsActivityView? + + /** + * Tell the Fragment to tell the containing Activity to show a new + * Fragment containing a submenu of settings. + * + * @param menuKey Identifier for the settings group that should be shown. + */ + fun loadSubMenu(menuKey: String) + + /** + * Tell the Fragment to tell the containing activity to display a toast message. + * + * @param message Text to be shown in the Toast + * @param is_long Whether this should be a long Toast or short one. + */ + fun showToastMessage(message: String?, is_long: Boolean) + + /** + * Have the fragment add a setting to the HashMap. + * + * @param setting The (possibly previously missing) new setting. + */ + fun putSetting(setting: AbstractSetting) + + /** + * Have the fragment tell the containing Activity that a setting was modified. + */ + fun onSettingChanged() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100644 index 000000000..04c045e77 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +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.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + val epochTime = setting.value.toLong() + val instant = Instant.ofEpochMilli(epochTime * 1000) + val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + binding.textSettingDescription.text = dateFormatter.format(zonedTime) + } + } + + 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.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100644 index 000000000..f5bcf705c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_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.setText(item.nameId) + } + + override fun onClick(clicked: View) { + // no-op + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt new file mode 100644 index 000000000..5dad5945f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: RunnableSetting + + override fun bind(item: SettingsItem) { + setting = item as RunnableSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + if (!setting.isRuntimeRunnable && !NativeLibrary.isRunning()) { + setting.runnable.invoke() + } + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt new file mode 100644 index 000000000..f56460893 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) : + RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener { + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + } + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + abstract fun bind(item: SettingsItem) + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + abstract override fun onClick(clicked: View) + + abstract override fun onLongClick(clicked: View): Boolean +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt new file mode 100644 index 000000000..de764a27f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SettingsItem + + override fun bind(item: SettingsItem) { + setting = item + binding.textSettingName.setText(item.nameId) + binding.textSettingDescription.visibility = View.VISIBLE + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + } else if (item is SingleChoiceSetting) { + val resMgr = binding.textSettingDescription.context.resources + val values = resMgr.getIntArray(item.valuesId) + for (i in values.indices) { + if (values[i] == item.selectedValue) { + binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i] + } + } + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + if (!setting.isEditable) { + return + } + + if (setting is SingleChoiceSetting) { + adapter.onSingleChoiceClick( + (setting as SingleChoiceSetting), + bindingAdapterPosition + ) + } else if (setting is StringSingleChoiceSetting) { + adapter.onStringSingleChoiceClick( + (setting as StringSingleChoiceSetting), + bindingAdapterPosition + ) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt new file mode 100644 index 000000000..cc3f39aa5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SliderSetting + + override fun bind(item: SettingsItem) { + setting = item as SliderSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onSliderClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100644 index 000000000..c545b4174 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var item: SubmenuSetting + + override fun bind(item: SettingsItem) { + this.item = item as SubmenuSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + adapter.onSubmenuClick(item) + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100644 index 000000000..b163bd6ca --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import android.widget.CompoundButton +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +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.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.text = "" + binding.textSettingDescription.visibility = View.GONE + } + binding.switchWidget.isChecked = setting.isChecked + binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked) + } + + binding.switchWidget.isEnabled = setting.isEditable + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + binding.switchWidget.toggle() + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt new file mode 100644 index 000000000..e29bca11d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.utils + +import org.ini4j.Wini +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.* +import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView +import org.yuzu.yuzu_emu.utils.BiMap +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.Log +import java.io.* +import java.util.* + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +object SettingsFile { + const val FILE_NAME_CONFIG = "config" + + private var sectionsMap = BiMap<String?, String?>() + + /** + * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param ini The ini file to load the settings from + * @param isCustomGame + * @param view The current view. + * @return An Observable that emits a HashMap of the file's contents, then completes. + */ + private fun readFile( + ini: File?, + isCustomGame: Boolean, + view: SettingsActivityView? = null + ): HashMap<String, SettingSection?> { + val sections: HashMap<String, SettingSection?> = SettingsSectionMap() + var reader: BufferedReader? = null + try { + reader = BufferedReader(FileReader(ini)) + var current: SettingSection? = null + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line!!.startsWith("[") && line!!.endsWith("]")) { + current = sectionFromLine(line!!, isCustomGame) + sections[current.name] = current + } else if (current != null) { + val setting = settingFromLine(line!!) + if (setting != null) { + current.putSetting(setting) + } + } + } + } catch (e: FileNotFoundException) { + Log.error("[SettingsFile] File not found: " + e.message) + view?.onSettingsFileNotFound() + } catch (e: IOException) { + Log.error("[SettingsFile] Error reading from: " + e.message) + view?.onSettingsFileNotFound() + } finally { + if (reader != null) { + try { + reader.close() + } catch (e: IOException) { + Log.error("[SettingsFile] Error closing: " + e.message) + } + } + } + return sections + } + + fun readFile(fileName: String, view: SettingsActivityView?): HashMap<String, SettingSection?> { + return readFile(getSettingsFile(fileName), false, view) + } + + fun readFile(fileName: String): HashMap<String, SettingSection?> = + readFile(getSettingsFile(fileName), false) + + /** + * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param gameId the id of the game to load it's settings. + * @param view The current view. + */ + fun readCustomGameSettings( + gameId: String, + view: SettingsActivityView? + ): HashMap<String, SettingSection?> { + return readFile(getCustomGameSettingsFile(gameId), true, view) + } + + /** + * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error + * telling why it failed. + * + * @param fileName The target filename without a path or extension. + * @param sections The HashMap containing the Settings we want to serialize. + * @param view The current view. + */ + fun saveFile( + fileName: String, + sections: TreeMap<String, SettingSection>, + view: SettingsActivityView + ) { + val ini = getSettingsFile(fileName) + try { + val writer = Wini(ini) + val keySet: Set<String> = sections.keys + for (key in keySet) { + val section = sections[key] + writeSection(writer, section!!) + } + writer.store() + } catch (e: IOException) { + Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message) + view.showToastMessage( + YuzuApplication.appContext + .getString(R.string.error_saving, fileName, e.message), + false + ) + } + } + + fun saveCustomGameSettings(gameId: String?, sections: HashMap<String, SettingSection?>) { + val sortedSections: Set<String> = TreeSet(sections.keys) + for (sectionKey in sortedSections) { + val section = sections[sectionKey] + val settings = section!!.settings + val sortedKeySet: Set<String> = TreeSet(settings.keys) + for (settingKey in sortedKeySet) { + val setting = settings[settingKey] + NativeLibrary.setUserSetting( + gameId, mapSectionNameFromIni( + section.name + ), setting!!.key, setting.valueAsString + ) + } + } + } + + private fun mapSectionNameFromIni(generalSectionName: String): String? { + return if (sectionsMap.getForward(generalSectionName) != null) { + sectionsMap.getForward(generalSectionName) + } else generalSectionName + } + + private fun mapSectionNameToIni(generalSectionName: String): String { + return if (sectionsMap.getBackward(generalSectionName) != null) { + sectionsMap.getBackward(generalSectionName).toString() + } else generalSectionName + } + + fun getSettingsFile(fileName: String): File { + return File( + DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini" + ) + } + + private fun getCustomGameSettingsFile(gameId: String): File { + return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini") + } + + private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection { + var sectionName: String = line.substring(1, line.length - 1) + if (isCustomGame) { + sectionName = mapSectionNameToIni(sectionName) + } + return SettingSection(sectionName) + } + + /** + * For a line of text, determines what type of data is being represented, and returns + * a Setting object containing this data. + * + * @param line The line of text being parsed. + * @return A typed Setting containing the key/value contained in the line. + */ + private fun settingFromLine(line: String): AbstractSetting? { + val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (splitLine.size != 2) { + return null + } + val key = splitLine[0].trim { it <= ' ' } + val value = splitLine[1].trim { it <= ' ' } + if (value.isEmpty()) { + return null + } + + val booleanSetting = BooleanSetting.from(key) + if (booleanSetting != null) { + booleanSetting.boolean = value.toBoolean() + return booleanSetting + } + + val intSetting = IntSetting.from(key) + if (intSetting != null) { + intSetting.int = value.toInt() + return intSetting + } + + val floatSetting = FloatSetting.from(key) + if (floatSetting != null) { + floatSetting.float = value.toFloat() + return floatSetting + } + + val stringSetting = StringSetting.from(key) + if (stringSetting != null) { + stringSetting.string = value + return stringSetting + } + + return null + } + + /** + * Writes the contents of a Section HashMap to disk. + * + * @param parser A Wini pointed at a file on disk. + * @param section A section containing settings to be written to the file. + */ + private fun writeSection(parser: Wini, section: SettingSection) { + // Write the section header. + val header = section.name + + // Write this section's values. + val settings = section.settings + val keySet: Set<String> = settings.keys + for (key in keySet) { + val setting = settings[key] + parser.put(header, setting!!.key, setting.valueAsString) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt new file mode 100644 index 000000000..c92e2755c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +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.view.ViewGroup.MarginLayoutParams +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 com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding +import org.yuzu.yuzu_emu.model.HomeViewModel + +class AboutFragment : Fragment() { + private var _binding: FragmentAboutBinding? = 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) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAboutBinding.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 { + binding.root.findNavController().popBackStack() + } + + binding.imageLogo.setOnLongClickListener { + Toast.makeText( + requireContext(), + R.string.gaia_is_not_real, + Toast.LENGTH_SHORT + ).show() + true + } + + binding.buttonContributors.setOnClickListener { openLink(getString(R.string.contributors_link)) } + binding.buttonLicenses.setOnClickListener { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + } + + binding.textBuildHash.text = BuildConfig.GIT_HASH + binding.buttonBuildHash.setOnClickListener { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) } + binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) } + binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_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 + + val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.appbarAbout.layoutParams = mlpAppBar + + val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.scrollAbout.layoutParams = mlpScrollAbout + + binding.contentAbout.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt new file mode 100644 index 000000000..d8bbc1ce4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding +import org.yuzu.yuzu_emu.model.HomeViewModel + +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 + + val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.appbarEa.layoutParams = mlpAppBar + + binding.scrollEa.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt new file mode 100644 index 000000000..9523381cd --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -0,0 +1,613 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.SharedPreferences +import android.content.pm.ActivityInfo +import android.content.res.Resources +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Rational +import android.util.TypedValue +import android.view.* +import android.widget.TextView +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.updatePadding +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding +import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.* +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var preferences: SharedPreferences + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private lateinit var game: Game + + 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) + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!! + emulationState = EmulationState(game.path) + } + + /** + * 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?) { + binding.surfaceEmulation.holder.addCallback(this) + binding.showFpsText.setTextColor(Color.YELLOW) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + // Setup overlay. + updateShowFpsOverlay() + + binding.inGameMenu.getHeaderView(0).findViewById<TextView>(R.id.text_game_title).text = + game.title + 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 + ) + } + true + } + + R.id.menu_settings -> { + SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_exit -> { + emulationState.stop() + requireActivity().finish() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open() + } + }) + } + + override fun onResume() { + super.onResume() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start(requireContext()) + } + + binding.surfaceEmulation.setAspectRatio( + when (IntSetting.RENDERER_ASPECT_RATIO.int) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + 4 -> null // Stretch + else -> Rational(16, 9) + } + ) + + emulationState.run(emulationActivity!!.isActivityRecreated) + } + + override fun onPause() { + if (emulationState.isRunning) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun refreshInputOverlay() { + binding.surfaceInputOverlay.refreshControls() + } + + private fun resetInputOverlay() { + preferences.edit() + .remove(Settings.PREF_CONTROL_SCALE) + .remove(Settings.PREF_CONTROL_OPACITY) + .apply() + binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() } + } + + private fun updateShowFpsOverlay() { + if (EmulationMenuSettings.showFps) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + val perfStats = NativeLibrary.getPerfStats() + if (perfStats[FPS] > 0 && _binding != null) { + binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS]) + } + + if (!emulationState.isStopped) { + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + binding.showFpsText.text = resources.getString(R.string.emulation_game_loading) + binding.showFpsText.visibility = View.VISIBLE + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + binding.showFpsText.visibility = View.GONE + } + } + + private val Number.toPx get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics).toInt() + + fun updateCurrentLayout(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) { + binding.surfaceEmulation.layoutParams.height = it.bounds.top + binding.inGameMenu.layoutParams.height = it.bounds.bottom + binding.overlayContainer.layoutParams.height = it.bounds.bottom - 48.toPx + binding.overlayContainer.updatePadding(0, 0, 0, 24.toPx) + } + } + it.isSeparating + } ?: false + if (!isFolding) { + binding.surfaceEmulation.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.overlayContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.overlayContainer.updatePadding(0, 0, 0, 0) + emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + } + binding.surfaceInputOverlay.requestLayout() + binding.inGameMenu.requestLayout() + binding.overlayContainer.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<View>(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 = EmulationMenuSettings.showFps + findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter + findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide + findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay + findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback + } + + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.showFps = it.isChecked + updateShowFpsOverlay() + 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 preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + val optionsArray = BooleanArray(15) + for (i in 0..14) { + optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13) + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + preferences.edit() + .putBoolean("buttonToggle$indexSelected", isChecked) + .apply() + } + .setPositiveButton(android.R.string.ok) { _, _ -> + refreshInputOverlay() + } + .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] + for (i in 0..14) { + optionsArray[i] = isChecked + dialog.listView.setItemChecked(i, isChecked) + preferences.edit() + .putBoolean("buttonToggle$i", isChecked) + .apply() + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.showOverlay = it.isChecked + refreshInputOverlay() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.joystickRelCenter = it.isChecked + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.dpadSlide = it.isChecked + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.hapticFeedback = it.isChecked + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + private fun startConfiguringControls() { + binding.doneControlConfig.visibility = View.VISIBLE + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + private fun stopConfiguringControls() { + binding.doneControlConfig.visibility = View.GONE + binding.surfaceInputOverlay.setIsInEditMode(false) + } + + @SuppressLint("SetTextI18n") + private fun adjustOverlay() { + val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) + adjustBinding.apply { + inputScaleSlider.apply { + valueTo = 150F + value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat() + addOnChangeListener(Slider.OnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + }) + } + inputOpacitySlider.apply { + valueTo = 100F + value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).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, null) + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + setControlOpacity(100) + } + .show() + } + + private fun setControlScale(scale: Int) { + preferences.edit() + .putInt(Settings.PREF_CONTROL_SCALE, scale) + .apply() + refreshInputOverlay() + } + + private fun setControlOpacity(opacity: Int) { + preferences.edit() + .putInt(Settings.PREF_CONTROL_OPACITY, opacity) + .apply() + refreshInputOverlay() + } + + 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.setPadding(left, cutInsets.top, right, 0) + + // Ensure FPS text doesn't get cut off by rounded display corners + val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) + if (cutInsets.left == 0) { + binding.showFpsText.setPadding( + sidePadding, + cutInsets.top, + cutInsets.right, + cutInsets.bottom + ) + } else { + binding.showFpsText.setPadding( + cutInsets.left, + cutInsets.top, + cutInsets.right, + cutInsets.bottom + ) + } + windowInsets + } + } + + private class EmulationState(private val gamePath: String) { + private var state: State + private var surface: Surface? = null + private var runWhenSurfaceIsValid = false + + 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) { + 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() + } else { + runWhenSurfaceIsValid = true + } + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (runWhenSurfaceIsValid) { + runWithValidSurface() + } + } + + @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() { + runWhenSurfaceIsValid = false + when (state) { + State.STOPPED -> { + NativeLibrary.surfaceChanged(surface) + val emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.surfaceChanged(surface) + 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()!!) + + fun newInstance(game: Game): EmulationFragment { + val args = Bundle() + args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game) + val fragment = EmulationFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt new file mode 100644 index 000000000..bdc337501 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.DocumentsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +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.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter +import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding +import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.HomeSetting +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GpuDriverHelper + +class HomeSettingsFragment : Fragment() { + private var _binding: FragmentHomeSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var mainActivity: MainActivity + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHomeSettingsBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + val optionsList: MutableList<HomeSetting> = mutableListOf( + HomeSetting( + R.string.advanced_settings, + R.string.settings_description, + R.drawable.ic_settings + ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }, + HomeSetting( + R.string.open_user_folder, + R.string.open_user_folder_description, + R.drawable.ic_folder_open + ) { openFileManager() }, + HomeSetting( + R.string.preferences_theme, + R.string.theme_and_color_description, + R.drawable.ic_palette + ) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") }, + HomeSetting( + R.string.install_gpu_driver, + R.string.install_gpu_driver_description, + R.drawable.ic_exit + ) { driverInstaller() }, + HomeSetting( + R.string.install_amiibo_keys, + R.string.install_amiibo_keys_description, + R.drawable.ic_nfc + ) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }, + HomeSetting( + R.string.select_games_folder, + R.string.select_games_folder_description, + R.drawable.ic_add + ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + HomeSetting( + R.string.manage_save_data, + R.string.import_export_saves_description, + R.drawable.ic_save + ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) }, + HomeSetting( + R.string.install_prod_keys, + R.string.install_prod_keys_description, + R.drawable.ic_unlock + ) { mainActivity.getProdKey.launch(arrayOf("*/*")) }, + HomeSetting( + R.string.install_firmware, + R.string.install_firmware_description, + R.drawable.ic_firmware + ) { mainActivity.getFirmware.launch(arrayOf("application/zip")) }, + HomeSetting( + R.string.share_log, + R.string.share_log_description, + R.drawable.ic_log + ) { shareLog() }, + HomeSetting( + R.string.about, + R.string.about_description, + R.drawable.ic_info_outline + ) { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) + } + ) + + if (!BuildConfig.PREMIUM) { + optionsList.add( + 0, + HomeSetting( + R.string.get_early_access, + R.string.get_early_access_description, + R.drawable.ic_diamond + ) { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) + } + ) + } + + binding.homeSettingsList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList) + } + + setInsets() + } + + override fun onStart() { + super.onStart() + exitTransition = null + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun openFileManager() { + // First, try to open the user data folder directly + try { + startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) + return + } catch (_: ActivityNotFoundException) { + } + + try { + startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) + return + } catch (_: ActivityNotFoundException) { + } + + // Just try to open the file manager, try the package name used on "normal" phones + try { + startActivity(getFileManagerIntent("com.google.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + try { + // Next, try the AOSP package name + startActivity(getFileManagerIntent("com.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + Toast.makeText( + requireContext(), + resources.getString(R.string.no_file_manager), + Toast.LENGTH_LONG + ).show() + } + + private fun getFileManagerIntent(packageName: String): Intent { + // Fragile, but some phones don't expose the system file manager in any better way + val intent = Intent(Intent.ACTION_MAIN) + intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + return intent + } + + private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { + val authority = "${requireContext().packageName}.user" + val intent = Intent(action) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + return intent + } + + private fun showNoLinkNotification() { + val builder = NotificationCompat.Builder( + requireContext(), + getString(R.string.notice_notification_channel_id) + ) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.notification_no_directory_link)) + .setContentText(getString(R.string.notification_no_directory_link_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + // TODO: Make the click action for this notification lead to a help article + + with(NotificationManagerCompat.from(requireContext())) { + if (ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Toast.makeText( + requireContext(), + resources.getString(R.string.notification_permission_not_granted), + Toast.LENGTH_LONG + ).show() + return + } + notify(0, builder.build()) + } + } + + private fun driverInstaller() { + // Get the driver name for the dialog message. + var driverName = GpuDriverHelper.customDriverName + if (driverName == null) { + driverName = getString(R.string.system_gpu_driver) + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.select_gpu_driver_title)) + .setMessage(driverName) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int -> + GpuDriverHelper.installDefaultDriver(requireContext()) + Toast.makeText( + requireContext(), + R.string.select_gpu_driver_use_default, + Toast.LENGTH_SHORT + ).show() + } + .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int -> + mainActivity.getDriver.launch(arrayOf("application/zip")) + } + .show() + } + + private fun shareLog() { + val file = DocumentFile.fromSingleUri( + mainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt" + ) + )!! + if (file.exists()) { + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(file.uri, FileUtil.TEXT_PLAIN) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, file.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else { + Toast.makeText( + requireContext(), + getText(R.string.share_log_missing), + Toast.LENGTH_SHORT + ).show() + } + } + + 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 spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.scrollViewSettings.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + ) + + val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams + mlpScrollSettings.leftMargin = leftInsets + mlpScrollSettings.rightMargin = rightInsets + binding.scrollViewSettings.layoutParams = mlpScrollSettings + + binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) + + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) + } else { + binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) + } + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt new file mode 100644 index 000000000..36e63bb9e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.getPublicFilesDir +import org.yuzu.yuzu_emu.utils.FileUtil +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.FilenameFilter +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class ImportExportSavesFragment : DialogFragment() { + private val context = YuzuApplication.appContext + private val savesFolder = + "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" + + // Get first subfolder in saves folder (should be the user folder) + private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" + private var lastZipCreated: File? = null + + private lateinit var startForResultExportSave: ActivityResultLauncher<Intent> + private lateinit var documentPicker: ActivityResultLauncher<Array<String>> + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val activity = requireActivity() as AppCompatActivity + + val activityResultRegistry = requireActivity().activityResultRegistry + startForResultExportSave = activityResultRegistry.register( + "startForResultExportSaveKey", + ActivityResultContracts.StartActivityForResult() + ) { + File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively() + } + documentPicker = activityResultRegistry.register( + "documentPickerKey", + ActivityResultContracts.OpenDocument() + ) { + it?.let { uri -> importSave(uri, activity) } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return if (savesFolderRoot == "") { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.manage_save_data) + .setMessage(R.string.import_export_saves_no_profile) + .setPositiveButton(android.R.string.ok, null) + .show() + } else { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.manage_save_data) + .setMessage(R.string.manage_save_data_description) + .setNegativeButton(R.string.export_saves) { _, _ -> + exportSave() + } + .setPositiveButton(R.string.import_saves) { _, _ -> + documentPicker.launch(arrayOf("application/zip")) + } + .setNeutralButton(android.R.string.cancel, null) + .show() + } + } + + /** + * Zips the save files located in the given folder path and creates a new zip file with the current date and time. + * @return true if the zip file is successfully created, false otherwise. + */ + private fun zipSave(): Boolean { + try { + val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp") + tempFolder.mkdirs() + val saveFolder = File(savesFolderRoot) + val outputZipFile = File( + tempFolder, + "yuzu saves - ${ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.zip" + ) + outputZipFile.createNewFile() + ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos -> + saveFolder.walkTopDown().forEach { file -> + val zipFileName = + file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/") + if (zipFileName == "") + return@forEach + val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") + zos.putNextEntry(entry) + if (file.isFile) + file.inputStream().use { fis -> fis.copyTo(zos) } + } + } + lastZipCreated = outputZipFile + } catch (e: Exception) { + return false + } + return true + } + + /** + * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. + */ + private fun exportSave() { + CoroutineScope(Dispatchers.IO).launch { + val wasZipCreated = zipSave() + val lastZipFile = lastZipCreated + if (!wasZipCreated || lastZipFile == null) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show() + } + return@launch + } + + withContext(Dispatchers.Main) { + val file = DocumentFile.fromSingleUri( + context, DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}" + ) + )!! + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(file.uri, "application/zip") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, file.uri) + startForResultExportSave.launch(Intent.createChooser(intent, "Share save file")) + } + } + } + + /** + * Imports the save files contained in the zip file, and replaces any existing ones with the new save file. + * @param zipUri The Uri of the zip file containing the save file(s) to import. + */ + private fun importSave(zipUri: Uri, activity: AppCompatActivity) { + val inputZip = context.contentResolver.openInputStream(zipUri) + // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid. + var validZip = false + val savesFolder = File(savesFolderRoot) + val cacheSaveDir = File("${context.cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + if (inputZip == null) { + Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) + .show() + return + } + + val filterTitleId = + FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } + + try { + CoroutineScope(Dispatchers.IO).launch { + FileUtil.unzip(inputZip, cacheSaveDir) + cacheSaveDir.list(filterTitleId)?.forEach { savePath -> + File(savesFolder, savePath).deleteRecursively() + File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true) + validZip = true + } + + withContext(Dispatchers.Main) { + if (!validZip) { + MessageDialogFragment.newInstance( + R.string.save_file_invalid_zip_structure, + R.string.save_file_invalid_zip_structure_description + ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + Toast.makeText( + context, + context.getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + } + + cacheSaveDir.deleteRecursively() + } + } catch (e: Exception) { + Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) + .show() + } + } + + companion object { + const val TAG = "ImportExportSavesFragment" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt new file mode 100644 index 000000000..c7880d8cc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.model.TaskViewModel + + +class IndeterminateProgressDialogFragment : DialogFragment() { + private val taskViewModel: TaskViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE) + + val progressBinding = DialogProgressBarBinding.inflate(layoutInflater) + progressBinding.progressBar.isIndeterminate = true + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleId) + .setView(progressBinding.root) + .create() + dialog.setCanceledOnTouchOutside(false) + + taskViewModel.isComplete.observe(this) { complete -> + if (complete) { + dialog.dismiss() + when (val result = taskViewModel.result.value) { + is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show() + is MessageDialogFragment -> result.show( + parentFragmentManager, + MessageDialogFragment.TAG + ) + } + taskViewModel.clear() + } + } + + if (taskViewModel.isRunning.value == false) { + taskViewModel.runTask() + } + return dialog + } + + companion object { + const val TAG = "IndeterminateProgressDialogFragment" + + private const val TITLE = "Title" + + fun newInstance( + activity: AppCompatActivity, + titleId: Int, + task: () -> Any + ): IndeterminateProgressDialogFragment { + val dialog = IndeterminateProgressDialogFragment() + val args = Bundle() + ViewModelProvider(activity)[TaskViewModel::class.java].task = task + args.putInt(TITLE, titleId) + dialog.arguments = args + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 000000000..78419191c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.databinding.DialogLicenseBinding +import org.yuzu.yuzu_emu.model.License +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogLicenseBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogLicenseBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from<View>(view.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable<License>(LICENSE)!! + + binding.apply { + textTitle.setText(license.titleId) + textLink.setText(license.linkId) + textCopyright.setText(license.copyrightId) + textLicense.setText(license.licenseId) + } + } + + companion object { + const val TAG = "LicenseBottomSheetDialogFragment" + + const val LICENSE = "License" + + fun newInstance( + license: License + ): LicenseBottomSheetDialogFragment { + val dialog = LicenseBottomSheetDialogFragment() + val bundle = Bundle() + bundle.putParcelable(LICENSE, license) + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt new file mode 100644 index 000000000..59141e823 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +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.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.LicenseAdapter +import org.yuzu.yuzu_emu.databinding.FragmentLicensesBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.License + +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 + + val mlpAppBar = binding.appbarLicenses.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.appbarLicenses.layoutParams = mlpAppBar + + val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.listLicenses.layoutParams = mlpScrollAbout + + binding.listLicenses.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt new file mode 100644 index 000000000..2db38fdc2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R + +class MessageDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE) + val descriptionId = requireArguments().getInt(DESCRIPTION) + val helpLinkId = requireArguments().getInt(HELP_LINK) + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.close, null) + .setTitle(titleId) + .setMessage(descriptionId) + + if (helpLinkId != 0) { + dialog.setNeutralButton(R.string.learn_more) { _, _ -> + openLink(getString(helpLinkId)) + } + } + + return dialog.show() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + companion object { + const val TAG = "MessageDialogFragment" + + private const val TITLE = "Title" + private const val DESCRIPTION = "Description" + private const val HELP_LINK = "Link" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int = 0 + ): MessageDialogFragment { + val dialog = MessageDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE, titleId) + putInt(DESCRIPTION, descriptionId) + putInt(HELP_LINK, helpLinkId) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100644 index 000000000..3478b9250 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100644 index 000000000..1b4b93ab8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt new file mode 100644 index 000000000..ebc0f164a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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 org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.Log +import java.util.Locale + +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?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.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 -> + if (text.toString().isNotEmpty()) { + binding.clearButton.visibility = View.VISIBLE + } else { + binding.clearButton.visibility = View.INVISIBLE + } + filterAndSearch() + } + + gamesViewModel.apply { + searchFocused.observe(viewLifecycleOwner) { searchFocused -> + if (searchFocused) { + focusSearch() + gamesViewModel.setSearchFocused(false) + } + } + + games.observe(viewLifecycleOwner) { filterAndSearch() } + searchedGames.observe(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noResultsView.visibility = View.VISIBLE + } else { + binding.noResultsView.visibility = View.GONE + } + } + } + + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + + binding.searchBackground.setOnClickListener { focusSearch() } + + setInsets() + filterAndSearch() + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun filterAndSearch() { + val baseList = gamesViewModel.games.value!! + val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) { + R.id.chip_recently_played -> { + baseList.filter { + val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) + lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + } + + R.id.chip_homebrew -> { + baseList.filter { + Log.error("Guh - ${it.path}") + FileUtil.hasExtension(it.path, "nro") + || FileUtil.hasExtension(it.path, "nso") + } + } + + R.id.chip_retail -> baseList.filter { + FileUtil.hasExtension(it.path, "xci") + || FileUtil.hasExtension(it.path, "nsp") + } + + else -> baseList + } + + if (binding.searchText.text.toString().isEmpty() + && binding.chipGroup.checkedChipId != View.NO_ID + ) { + gamesViewModel.setSearchedGames(filteredList) + return + } + + val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) + val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() + val sortedList: List<Game> = filteredList.mapNotNull { game -> + val title = game.title.lowercase(Locale.getDefault()) + val score = searchAlgorithm.similarity(searchTerm, title) + if (score > 0.03) { + ScoredGame(score, game) + } else { + null + } + }.sortedByDescending { it.score }.map { it.item } + gamesViewModel.setSearchedGames(sortedList) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + } + + private fun focusSearch() { + if (_binding != null) { + binding.searchText.requestFocus() + val imm = + requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + binding.constraintSearch.updatePadding( + left = barInsets.left + cutoutInsets.left, + top = barInsets.top, + right = barInsets.right + cutoutInsets.right + ) + + binding.gridGamesSearch.updatePadding( + top = extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) + + val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.frameSearch.updatePadding(left = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) + binding.noResultsView.updatePadding(left = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing + spacingNavigationRail, + right = chipSpacing + ) + mlpDivider.leftMargin = chipSpacing + spacingNavigationRail + mlpDivider.rightMargin = chipSpacing + } else { + binding.frameSearch.updatePadding(right = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) + binding.noResultsView.updatePadding(right = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing, + right = chipSpacing + spacingNavigationRail + ) + mlpDivider.leftMargin = chipSpacing + mlpDivider.rightMargin = chipSpacing + spacingNavigationRail + } + binding.divider.layoutParams = mlpDivider + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt new file mode 100644 index 000000000..258773380 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.google.android.material.transition.MaterialFadeThrough +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.SetupAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.SetupPage +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.GameHelper +import java.io.File + +class SetupFragment : Fragment() { + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var mainActivity: MainActivity + + private lateinit var hasBeenWarned: BooleanArray + + companion object { + const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" + const val KEY_BACK_VISIBILITY = "BackButtonVisibility" + const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + exitTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSetupBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = false) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.viewPager2.currentItem > 0) { + pageBackward() + } else { + requireActivity().finish() + } + } + }) + + requireActivity().window.navigationBarColor = + ContextCompat.getColor(requireContext(), android.R.color.transparent) + + val pages = mutableListOf<SetupPage>() + pages.apply { + add( + SetupPage( + R.drawable.ic_yuzu_title, + R.string.welcome, + R.string.welcome_description, + 0, + true, + R.string.get_started, + { pageForward() }, + false + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add( + SetupPage( + R.drawable.ic_notification, + R.string.notifications, + R.string.notifications_description, + 0, + false, + R.string.give_permission, + { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }, + true, + R.string.notification_warning, + R.string.notification_warning_description, + 0, + { + NotificationManagerCompat.from(requireContext()) + .areNotificationsEnabled() + } + ) + ) + } + + add( + SetupPage( + R.drawable.ic_key, + R.string.keys, + R.string.keys_description, + R.drawable.ic_add, + true, + R.string.select_keys, + { mainActivity.getProdKey.launch(arrayOf("*/*")) }, + true, + R.string.install_prod_keys_warning, + R.string.install_prod_keys_warning_description, + R.string.install_prod_keys_warning_help, + { File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() } + ) + ) + add( + SetupPage( + R.drawable.ic_controller, + R.string.games, + R.string.games_description, + R.drawable.ic_add, + true, + R.string.add_games, + { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + true, + R.string.add_games_warning, + R.string.add_games_warning_description, + R.string.add_games_warning_help, + { + val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty() + } + ) + ) + add( + SetupPage( + R.drawable.ic_check, + R.string.done, + R.string.done_description, + R.drawable.ic_arrow_forward, + false, + R.string.text_continue, + { finishSetup() }, + false + ) + ) + } + + binding.viewPager2.apply { + adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages) + offscreenPageLimit = 2 + isUserInputEnabled = false + } + + binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() { + var previousPosition: Int = 0 + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + + if (position == 1 && previousPosition == 0) { + showView(binding.buttonNext) + showView(binding.buttonBack) + } else if (position == 0 && previousPosition == 1) { + hideView(binding.buttonBack) + hideView(binding.buttonNext) + } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { + hideView(binding.buttonNext) + } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { + showView(binding.buttonNext) + } + + previousPosition = position + } + }) + + binding.buttonNext.setOnClickListener { + val index = binding.viewPager2.currentItem + val currentPage = pages[index] + + // Checks if the user has completed the task on the current page + if (currentPage.hasWarning) { + if (currentPage.taskCompleted.invoke()) { + pageForward() + return@setOnClickListener + } + + if (!hasBeenWarned[index]) { + SetupWarningDialogFragment.newInstance( + currentPage.warningTitleId, + currentPage.warningDescriptionId, + currentPage.warningHelpLinkId, + index + ).show(childFragmentManager, SetupWarningDialogFragment.TAG) + return@setOnClickListener + } + } + pageForward() + } + binding.buttonBack.setOnClickListener { pageBackward() } + + if (savedInstanceState != null) { + val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) + val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) + hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! + + if (nextIsVisible) { + binding.buttonNext.visibility = View.VISIBLE + } + if (backIsVisible) { + binding.buttonBack.visibility = View.VISIBLE + } + } else { + hasBeenWarned = BooleanArray(pages.size) + } + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) + outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + PermissionDeniedDialogFragment().show( + childFragmentManager, + PermissionDeniedDialogFragment.TAG + ) + } + } + + private fun finishSetup() { + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() + .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) + .apply() + mainActivity.finishSetup(binding.root.findNavController()) + } + + private fun showView(view: View) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = 300 + alpha(1f) + }.start() + } + + private fun hideView(view: View) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = 300 + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + } + } + + fun pageForward() { + binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 + } + + fun pageBackward() { + binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 + } + + fun setPageWarned(page: Int) { + hasBeenWarned[page] = true + } + + 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()) + view.setPadding( + barInsets.left + cutoutInsets.left, + barInsets.top + cutoutInsets.top, + barInsets.right + cutoutInsets.right, + barInsets.bottom + cutoutInsets.bottom + ) + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt new file mode 100644 index 000000000..b2c1d54af --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R + +class SetupWarningDialogFragment : DialogFragment() { + private var titleId: Int = 0 + private var descriptionId: Int = 0 + private var helpLinkId: Int = 0 + private var page: Int = 0 + + private lateinit var setupFragment: SetupFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + titleId = requireArguments().getInt(TITLE) + descriptionId = requireArguments().getInt(DESCRIPTION) + helpLinkId = requireArguments().getInt(HELP_LINK) + page = requireArguments().getInt(PAGE) + + setupFragment = requireParentFragment() as SetupFragment + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int -> + setupFragment.pageForward() + setupFragment.setPageWarned(page) + } + .setNegativeButton(R.string.warning_cancel, null) + + if (titleId != 0) { + builder.setTitle(titleId) + } else { + builder.setTitle("") + } + if (descriptionId != 0) { + builder.setMessage(descriptionId) + } + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int -> + val helpLink = resources.getString(R.string.install_prod_keys_warning_help) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink)) + startActivity(intent) + } + } + + return builder.show() + } + + companion object { + const val TAG = "SetupWarningDialogFragment" + + private const val TITLE = "Title" + private const val DESCRIPTION = "Description" + private const val HELP_LINK = "HelpLink" + private const val PAGE = "Page" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int, + page: Int + ): SetupWarningDialogFragment { + val dialog = SetupWarningDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE, titleId) + putInt(DESCRIPTION, descriptionId) + putInt(HELP_LINK, helpLinkId) + putInt(PAGE, page) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt new file mode 100644 index 000000000..be5e4c86c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.layout + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt new file mode 100644 index 000000000..2a17653b2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.util.HashSet + +@Parcelize +@Serializable +class Game( + val title: String, + val description: String, + val regions: String, + val path: String, + val gameId: String, + val company: String +) : Parcelable { + val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${gameId}_LastPlayed" + + override fun equals(other: Any?): Boolean { + if (other !is Game) + return false + + return title == other.title + && description == other.description + && regions == other.regions + && path == other.path + && gameId == other.gameId + && company == other.company + } + + companion object { + val extensions: Set<String> = HashSet( + listOf(".xci", ".nsp", ".nca", ".nro") + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt new file mode 100644 index 000000000..7059856f1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.utils.GameHelper +import java.util.Locale + +class GamesViewModel : ViewModel() { + private val _games = MutableLiveData<List<Game>>(emptyList()) + val games: LiveData<List<Game>> get() = _games + + private val _searchedGames = MutableLiveData<List<Game>>(emptyList()) + val searchedGames: LiveData<List<Game>> get() = _searchedGames + + private val _isReloading = MutableLiveData(false) + val isReloading: LiveData<Boolean> get() = _isReloading + + private val _shouldSwapData = MutableLiveData(false) + val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData + + private val _shouldScrollToTop = MutableLiveData(false) + val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop + + private val _searchFocused = MutableLiveData(false) + val searchFocused: LiveData<Boolean> get() = _searchFocused + + init { + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + // Retrieve list of cached games + val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf<Game>() + storedGames.forEach { + val game: Game = Json.decodeFromString(it) + val gameExists = + DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path)) + ?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + reloadGames(false) + } + + fun setGames(games: List<Game>) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.postValue(sortedList) + } + + fun setSearchedGames(games: List<Game>) { + _searchedGames.postValue(games) + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.postValue(shouldSwap) + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.postValue(shouldScroll) + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.postValue(searchFocused) + } + + fun reloadGames(directoryChanged: Boolean) { + if (isReloading.value == true) + return + _isReloading.postValue(true) + + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeLibrary.resetRomMetadata() + setGames(GameHelper.getGames()) + _isReloading.postValue(false) + + if (directoryChanged) { + setShouldSwapData(true) + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt new file mode 100644 index 000000000..7049f2fa5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +data class HomeSetting( + val titleId: Int, + val descriptionId: Int, + val iconId: Int, + val onClick: () -> Unit +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt new file mode 100644 index 000000000..263ee7144 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class HomeViewModel : ViewModel() { + private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>() + val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible + + private val _statusBarShadeVisible = MutableLiveData(true) + val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible + + var navigatedToSetup = false + + init { + _navigationVisible.value = Pair(false, 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 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt new file mode 100644 index 000000000..f24d5cf34 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt new file mode 100644 index 000000000..b4b78e42d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import android.provider.DocumentsContract + +class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) { + val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt new file mode 100644 index 000000000..a0c878e1c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +data class SetupPage( + val iconId: Int, + val titleId: Int, + val descriptionId: Int, + val buttonIconId: Int, + val leftAlignedIcon: Boolean, + val buttonTextId: Int, + val buttonAction: () -> Unit, + val hasWarning: Boolean, + val warningTitleId: Int = 0, + val warningDescriptionId: Int = 0, + val warningHelpLinkId: Int = 0, + val taskCompleted: () -> Boolean = { true } +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt new file mode 100644 index 000000000..27ea725a5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class TaskViewModel : ViewModel() { + private val _result = MutableLiveData<Any>() + val result: LiveData<Any> = _result + + private val _isComplete = MutableLiveData<Boolean>() + val isComplete: LiveData<Boolean> = _isComplete + + private val _isRunning = MutableLiveData<Boolean>() + val isRunning: LiveData<Boolean> = _isRunning + + lateinit var task: () -> Any + + init { + clear() + } + + fun clear() { + _result.value = Any() + _isComplete.value = false + _isRunning.value = false + } + + fun runTask() { + if (_isRunning.value == true) { + return + } + _isRunning.value = true + + viewModelScope.launch(Dispatchers.IO) { + val res = task() + _result.postValue(res) + _isComplete.postValue(true) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt new file mode 100644 index 000000000..c9f5797ac --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -0,0 +1,1064 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.VectorDrawable +import android.os.Build +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import android.view.WindowInsets +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import androidx.window.layout.WindowMetricsCalculator +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.NativeLibrary.ButtonType +import org.yuzu.yuzu_emu.NativeLibrary.StickType +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.utils.EmulationMenuSettings +import kotlin.math.max +import kotlin.math.min + +/** + * Draws the interactive input overlay on top of the + * [SurfaceView] that is rendering emulation. + */ +class InputOverlay(context: Context, attrs: AttributeSet?) : SurfaceView(context, attrs), + OnTouchListener { + private val overlayButtons: MutableSet<InputOverlayDrawableButton> = HashSet() + private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = HashSet() + private val overlayJoysticks: MutableSet<InputOverlayDrawableJoystick> = HashSet() + + private var inEditMode = false + private var buttonBeingConfigured: InputOverlayDrawableButton? = null + private var dpadBeingConfigured: InputOverlayDrawableDpad? = null + private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null + + private lateinit var windowInsets: WindowInsets + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + windowInsets = rootWindowInsets + + if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) { + defaultOverlay() + } + + // Load the controls. + refreshControls() + + // Set the on touch listener. + setOnTouchListener(this) + + // Force draw + setWillNotDraw(false) + + // Request focus for the overlay so it has priority on presses. + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + for (button in overlayButtons) { + button.draw(canvas) + } + for (dpad in overlayDpads) { + dpad.draw(canvas) + } + for (joystick in overlayJoysticks) { + joystick.draw(canvas) + } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (inEditMode) { + return onTouchWhileEditing(event) + } + + var shouldUpdateView = false + val playerIndex = + if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device + + for (button in overlayButtons) { + if (!button.updateStatus(event)) { + continue + } + NativeLibrary.onGamePadButtonEvent( + playerIndex, + button.buttonId, + button.status + ) + playHaptics(event) + shouldUpdateView = true + } + + for (dpad in overlayDpads) { + if (!dpad.updateStatus(event, EmulationMenuSettings.dpadSlide)) { + continue + } + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.upId, + dpad.upStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.downId, + dpad.downStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.leftId, + dpad.leftStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.rightId, + dpad.rightStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + for (joystick in overlayJoysticks) { + if (!joystick.updateStatus(event)) { + continue + } + val axisID = joystick.joystickId + NativeLibrary.onGamePadJoystickEvent( + playerIndex, + axisID, + joystick.xAxis, + joystick.realYAxis + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + joystick.buttonId, + joystick.buttonStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + if (shouldUpdateView) + invalidate() + + if (!preferences.getBoolean(Settings.PREF_TOUCH_ENABLED, true)) { + return true + } + + 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 isActionMove = motionEvent == MotionEvent.ACTION_MOVE + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) + } + + if (isActionMove) { + for (i in 0 until event.pointerCount) { + val fingerId = event.getPointerId(i) + if (isTouchInputConsumed(fingerId)) { + continue + } + NativeLibrary.onTouchMoved(fingerId, event.getX(i), event.getY(i)) + } + } + + if (isActionUp && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchReleased(pointerId) + } + + return true + } + + private fun playHaptics(event: MotionEvent) { + if (EmulationMenuSettings.hapticFeedback) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) + } + } + } + + private fun isTouchInputConsumed(track_id: Int): Boolean { + for (button in overlayButtons) { + if (button.trackId == track_id) { + return true + } + } + for (dpad in overlayDpads) { + if (dpad.trackId == track_id) { + return true + } + } + for (joystick in overlayJoysticks) { + if (joystick.trackId == track_id) { + return true + } + } + return false + } + + private fun onTouchWhileEditing(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + // TODO: Provide support for portrait layout + //val orientation = + // if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else "" + + for (button in overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + button.bounds.contains( + fingerPositionX, + fingerPositionY + ) + ) { + buttonBeingConfigured = button + buttonBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { + buttonBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) { + // Persist button position by saving new place. + saveControlPosition( + buttonBeingConfigured!!.buttonId, + buttonBeingConfigured!!.bounds.centerX(), + buttonBeingConfigured!!.bounds.centerY(), + "" + ) + buttonBeingConfigured = null + } + } + } + + for (dpad in overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + dpad.bounds.contains(fingerPositionX, fingerPositionY) + ) { + dpadBeingConfigured = dpad + dpadBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { + dpadBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) { + // Persist button position by saving new place. + saveControlPosition( + dpadBeingConfigured!!.upId, + dpadBeingConfigured!!.bounds.centerX(), + dpadBeingConfigured!!.bounds.centerY(), + "" + ) + dpadBeingConfigured = null + } + } + } + + for (joystick in overlayJoysticks) { + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && + joystick.bounds.contains( + fingerPositionX, + fingerPositionY + ) + ) { + joystickBeingConfigured = joystick + joystickBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { + joystickBeingConfigured!!.onConfigureTouch(event) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { + saveControlPosition( + joystickBeingConfigured!!.buttonId, + joystickBeingConfigured!!.bounds.centerX(), + joystickBeingConfigured!!.bounds.centerY(), + "" + ) + joystickBeingConfigured = null + } + } + } + + return true + } + + private fun addOverlayControls(orientation: String) { + val windowSize = getSafeScreenSize(context) + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_0, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_a, + R.drawable.facebutton_a_depressed, + ButtonType.BUTTON_A, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_1, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_b, + R.drawable.facebutton_b_depressed, + ButtonType.BUTTON_B, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_2, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_x, + R.drawable.facebutton_x_depressed, + ButtonType.BUTTON_X, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_3, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_y, + R.drawable.facebutton_y_depressed, + ButtonType.BUTTON_Y, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_4, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.l_shoulder, + R.drawable.l_shoulder_depressed, + ButtonType.TRIGGER_L, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_5, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.r_shoulder, + R.drawable.r_shoulder_depressed, + ButtonType.TRIGGER_R, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_6, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zl_trigger, + R.drawable.zl_trigger_depressed, + ButtonType.TRIGGER_ZL, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_7, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zr_trigger, + R.drawable.zr_trigger_depressed, + ButtonType.TRIGGER_ZR, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_8, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_plus, + R.drawable.facebutton_plus_depressed, + ButtonType.BUTTON_PLUS, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_9, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_minus, + R.drawable.facebutton_minus_depressed, + ButtonType.BUTTON_MINUS, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_10, true)) { + overlayDpads.add( + initializeOverlayDpad( + context, + windowSize, + R.drawable.dpad_standard, + R.drawable.dpad_standard_cardinal_depressed, + R.drawable.dpad_standard_diagonal_depressed, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_11, true)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + StickType.STICK_L, + ButtonType.STICK_L, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_12, true)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + StickType.STICK_R, + ButtonType.STICK_R, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_13, false)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_home, + R.drawable.facebutton_home_depressed, + ButtonType.BUTTON_HOME, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_14, false)) { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_screenshot, + R.drawable.facebutton_screenshot_depressed, + ButtonType.BUTTON_CAPTURE, + orientation + ) + ) + } + } + + fun refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear() + overlayDpads.clear() + overlayJoysticks.clear() + val orientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else "" + + // Add all the enabled overlay items back to the HashSet. + if (EmulationMenuSettings.showOverlay) { + addOverlayControls(orientation) + } + invalidate() + } + + private fun saveControlPosition(sharedPrefsId: Int, x: Int, y: Int, orientation: String) { + val windowSize = getSafeScreenSize(context) + val min = windowSize.first + val max = windowSize.second + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() + .putFloat("$sharedPrefsId$orientation-X", (x - min.x).toFloat() / max.x) + .putFloat("$sharedPrefsId$orientation-Y", (y - min.y).toFloat() / max.y) + .apply() + } + + fun setIsInEditMode(editMode: Boolean) { + inEditMode = editMode + } + + private fun defaultOverlay() { + if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) { + defaultOverlayLandscape() + } + + resetButtonPlacement() + preferences.edit() + .putBoolean(Settings.PREF_OVERLAY_INIT, true) + .apply() + } + + fun resetButtonPlacement() { + defaultOverlayLandscape() + refreshControls() + } + + private fun defaultOverlayLandscape() { + // Each value represents the position of the button in relation to the screen size without insets. + preferences.edit() + .putFloat( + ButtonType.BUTTON_A.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_A_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_A.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_A_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_B.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_B_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_B.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_B_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_X.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_X_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_X.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_X_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_Y.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_Y_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_Y.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_Y_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_ZL.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_ZL.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_ZR.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_ZR.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.DPAD_UP.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.DPAD_UP.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_L.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_L_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_L.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_L_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_R.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_R_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.TRIGGER_R.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_R_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_PLUS.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_PLUS.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_MINUS.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_MINUS.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_HOME.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_HOME_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_HOME.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_HOME_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_CAPTURE.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_X) + .toFloat() / 1000 + ) + .putFloat( + ButtonType.BUTTON_CAPTURE.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_Y) + .toFloat() / 1000 + ) + .putFloat( + ButtonType.STICK_R.toString() + "-X", + resources.getInteger(R.integer.SWITCH_STICK_R_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.STICK_R.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_STICK_R_Y).toFloat() / 1000 + ) + .putFloat( + ButtonType.STICK_L.toString() + "-X", + resources.getInteger(R.integer.SWITCH_STICK_L_X).toFloat() / 1000 + ) + .putFloat( + ButtonType.STICK_L.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_STICK_L_Y).toFloat() / 1000 + ) + .apply() + } + + override fun isInEditMode(): Boolean { + return inEditMode + } + + companion object { + private val preferences: SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context Context for getting the vector drawable + * @param drawableId The ID of the drawable to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { + val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable + + val bitmap = Bitmap.createBitmap( + (vectorDrawable.intrinsicWidth * scale).toInt(), + (vectorDrawable.intrinsicHeight * scale).toInt(), + Bitmap.Config.ARGB_8888 + ) + + val dm = context.resources.displayMetrics + val minScreenDimension = min(dm.widthPixels, dm.heightPixels) + + val maxBitmapDimension = max(bitmap.width, bitmap.height) + val bitmapScale = scale * minScreenDimension / maxBitmapDimension + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + (bitmap.width * bitmapScale).toInt(), + (bitmap.height * bitmapScale).toInt(), + true + ) + + val canvas = Canvas(scaledBitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return scaledBitmap + } + + /** + * Gets the safe screen size for drawing the overlay + * + * @param context Context for getting the window metrics + * @return A pair of points, the first being the top left corner of the safe area, + * the second being the bottom right corner of the safe area + */ + private fun getSafeScreenSize(context: Context): Pair<Point, Point> { + // Get screen size + val windowMetrics = + WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(context as Activity) + var maxY = windowMetrics.bounds.height().toFloat() + var maxX = windowMetrics.bounds.width().toFloat() + var minY = 0 + var minX = 0 + + // If we have API access, calculate the safe area to draw the overlay + var cutoutLeft = 0 + var cutoutBottom = 0 + val insets = context.windowManager.currentWindowMetrics.windowInsets.displayCutout + if (insets != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (insets.boundingRectTop.bottom != 0 && insets.boundingRectTop.bottom > maxY / 2) + insets.boundingRectTop.bottom.toFloat() else maxY + if (insets.boundingRectRight.left != 0 && insets.boundingRectRight.left > maxX / 2) + insets.boundingRectRight.left.toFloat() else maxX + + minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left + minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom + + cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left + cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom + } + + // This makes sure that if we have an inset on one side of the screen, we mirror it on + // the other side. Since removing space from one of the max values messes with the scale, + // we also have to account for it using our min values. + if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft + if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom + if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) { + maxX -= (minX * 2) + } else if (minX > 0) { + maxX -= minX + } + if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) { + maxY -= (minY * 2) + } else if (minY > 0) { + maxY -= minY + } + + return Pair(Point(minX, minY), Point(maxX.toInt(), maxY.toInt())) + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + * + * + * This works due to the way the X and Y coordinates are stored within + * the [SharedPreferences]. + * + * + * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored + * within a SharedPreferences instance so that those values can be retrieved here. + * + * + * This has a few benefits over the conventional way of storing the values + * (ie. within the yuzu ini file). + * + * * No native calls + * * Keeps Android-only values inside the Android environment + * + * + * + * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). + * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. + */ + private fun initializeOverlayButton( + context: Context, + windowSize: Pair<Point, Point>, + defaultResId: Int, + pressedResId: Int, + buttonId: Int, + orientation: String + ): InputOverlayDrawableButton { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on button ID and user preference + var scale: Float = when (buttonId) { + ButtonType.BUTTON_HOME, + ButtonType.BUTTON_CAPTURE, + ButtonType.BUTTON_PLUS, + ButtonType.BUTTON_MINUS -> 0.07f + + ButtonType.TRIGGER_L, + ButtonType.TRIGGER_R, + ButtonType.TRIGGER_ZL, + ButtonType.TRIGGER_ZR -> 0.26f + + else -> 0.11f + } + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableButton. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedStateBitmap = getBitmap(context, pressedResId, scale) + val overlayDrawable = + InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val xKey = "$buttonId$orientation-X" + val yKey = "$buttonId$orientation-Y" + val drawableXPercent = sPrefs.getFloat(xKey, 0f) + val drawableYPercent = sPrefs.getFloat(yKey, 0f) + val drawableX = (drawableXPercent * max.x + min.x).toInt() + val drawableY = (drawableYPercent * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition( + drawableX - (width / 2), + drawableY - (height / 2) + ) + val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100) + overlayDrawable.setOpacity(savedOpacity * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableDpad] + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The [Bitmap] resource ID of the default state. + * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed state in one direction. + * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed state in two directions. + * @return the initialized [InputOverlayDrawableDpad] + */ + private fun initializeOverlayDpad( + context: Context, + windowSize: Pair<Point, Point>, + defaultResId: Int, + pressedOneDirectionResId: Int, + pressedTwoDirectionsResId: Int, + orientation: String + ): InputOverlayDrawableDpad { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on button ID and user preference + var scale = 0.25f + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableDpad. + val defaultStateBitmap = + getBitmap(context, defaultResId, scale) + val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) + val pressedTwoDirectionsStateBitmap = + getBitmap(context, pressedTwoDirectionsResId, scale) + + val overlayDrawable = InputOverlayDrawableDpad( + res, + defaultStateBitmap, + pressedOneDirectionStateBitmap, + pressedTwoDirectionsStateBitmap, + ButtonType.DPAD_UP, + ButtonType.DPAD_DOWN, + ButtonType.DPAD_LEFT, + ButtonType.DPAD_RIGHT + ) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableXPercent = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-X", 0f) + val drawableYPercent = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-Y", 0f) + val drawableX = (drawableXPercent * max.x + min.x).toInt() + val drawableY = (drawableYPercent * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX - (width / 2), drawableY - (height / 2)) + val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100) + overlayDrawable.setOpacity(savedOpacity * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableJoystick] + * + * @param context The current [Context] + * @param windowSize The size of the window to draw the overlay on. + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @param button Identifier for which joystick button this is. + * @return the initialized [InputOverlayDrawableJoystick]. + */ + private fun initializeOverlayJoystick( + context: Context, + windowSize: Pair<Point, Point>, + resOuter: Int, + defaultResInner: Int, + pressedResInner: Int, + joystick: Int, + button: Int, + orientation: String + ): InputOverlayDrawableJoystick { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on user preference + var scale = 0.3f + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableJoystick. + val bitmapOuter = getBitmap(context, resOuter, scale) + val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f) + val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableXPercent = sPrefs.getFloat("$button$orientation-X", 0f) + val drawableYPercent = sPrefs.getFloat("$button$orientation-Y", 0f) + val drawableX = (drawableXPercent * max.x + min.x).toInt() + val drawableY = (drawableYPercent * max.y + min.y).toInt() + val outerScale = 1.66f + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + val outerSize = bitmapOuter.width + val outerRect = Rect( + drawableX - (outerSize / 2), + drawableY - (outerSize / 2), + drawableX + (outerSize / 2), + drawableY + (outerSize / 2) + ) + val innerRect = + Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) + + // Send the drawableId to the joystick so it can be referenced when saving control position. + val overlayDrawable = InputOverlayDrawableJoystick( + res, + bitmapOuter, + bitmapInnerDefault, + bitmapInnerPressed, + outerRect, + innerRect, + joystick, + button + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100) + overlayDrawable.setOpacity(savedOpacity * 255 / 100) + return overlayDrawable + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 000000000..4a93e0b14 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary.ButtonState + +/** + * 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 buttonId Identifier for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val buttonId: Int +) { + // 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/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 000000000..43d664d21 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary.ButtonState + +/** + * 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. + * @param buttonUp Identifier for the up button. + * @param buttonDown Identifier for the down button. + * @param buttonLeft Identifier for the left button. + * @param buttonRight Identifier for the right button. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap, + buttonUp: Int, + buttonDown: Int, + buttonLeft: Int, + buttonRight: Int +) { + /** + * 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 upId: Int + val downId: Int + val leftId: Int + val rightId: Int + 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 + upId = buttonUp + downId = buttonDown + leftId = buttonLeft + rightId = buttonRight + 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/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt new file mode 100644 index 000000000..f1d32192a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.EmulationMenuSettings +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. + * @param rectOuter [Rect] which represents the outer joystick bounds. + * @param rectInner [Rect] which represents the inner joystick bounds. + * @param joystickId The ID value what type of joystick this Drawable represents. + * @param buttonId The ID value what type of button this Drawable represents. + */ +class InputOverlayDrawableJoystick( + res: Resources, + bitmapOuter: Bitmap, + bitmapInnerDefault: Bitmap, + bitmapInnerPressed: Bitmap, + rectOuter: Rect, + rectInner: Rect, + val joystickId: Int, + val buttonId: Int +) { + // The ID value what motion event is tracking + var trackId = -1 + + var xAxis = 0f + private var yAxis = 0f + + val width: Int + val height: Int + + private var opacity: Int = 0 + + private var virtBounds: Rect + private var origBounds: Rect + + private val outerBitmap: BitmapDrawable + private val defaultStateInnerBitmap: BitmapDrawable + private val pressedStateInnerBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + private val boundsBoxBitmap: BitmapDrawable + + private var pressedState = false + + // TODO: Add button support + val buttonStatus: Int + get() = + NativeLibrary.ButtonState.RELEASED + var bounds: Rect + get() = outerBitmap.bounds + set(bounds) { + outerBitmap.bounds = bounds + } + + // Nintendo joysticks have y axis inverted + val realYAxis: Float + get() = -yAxis + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap + + init { + outerBitmap = BitmapDrawable(res, bitmapOuter) + defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) + pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) + boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) + width = bitmapOuter.width + height = bitmapOuter.height + bounds = rectOuter + defaultStateInnerBitmap.bounds = rectInner + pressedStateInnerBitmap.bounds = rectInner + virtBounds = bounds + origBounds = outerBitmap.copyBounds() + boundsBoxBitmap.alpha = 0 + boundsBoxBitmap.bounds = virtBounds + setInnerBounds() + } + + fun draw(canvas: Canvas?) { + outerBitmap.draw(canvas!!) + currentStateBitmapDrawable.draw(canvas) + boundsBoxBitmap.draw(canvas) + } + + 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 + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = opacity + if (EmulationMenuSettings.joystickRelCenter) { + virtBounds.offset( + xPosition - virtBounds.centerX(), + yPosition - virtBounds.centerY() + ) + } + boundsBoxBitmap.bounds = virtBounds + trackId = pointerId + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + xAxis = 0.0f + yAxis = 0.0f + outerBitmap.alpha = opacity + boundsBoxBitmap.alpha = 0 + virtBounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + bounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + setInnerBounds() + trackId = -1 + return true + } + + if (trackId == -1) 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 = virtBounds.bottom.toFloat() + var maxX = virtBounds.right.toFloat() + touchX -= virtBounds.centerX().toFloat() + maxX -= virtBounds.centerX().toFloat() + touchY -= virtBounds.centerY().toFloat() + maxY -= virtBounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldXAxis = xAxis + val oldYAxis = yAxis + + // Clamp the circle pad input to a circle + val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat() + var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat() + if (radius > 1.0f) { + radius = 1.0f + } + xAxis = cos(angle.toDouble()).toFloat() * radius + yAxis = sin(angle.toDouble()).toFloat() * radius + setInnerBounds() + return oldXAxis != xAxis && oldYAxis != yAxis + } + return false + } + + 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 + bounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + virtBounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + setInnerBounds() + bounds = Rect( + Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + origBounds = outerBitmap.copyBounds() + return true + } + + private fun setInnerBounds() { + var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() + var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() + if (x > virtBounds.centerX() + virtBounds.width() / 2) x = + virtBounds.centerX() + virtBounds.width() / 2 + if (x < virtBounds.centerX() - virtBounds.width() / 2) x = + virtBounds.centerX() - virtBounds.width() / 2 + if (y > virtBounds.centerY() + virtBounds.height() / 2) y = + virtBounds.centerY() + virtBounds.height() / 2 + if (y < virtBounds.centerY() - virtBounds.height() / 2) y = + virtBounds.centerY() - virtBounds.height() / 2 + val width = pressedStateInnerBitmap.bounds.width() / 2 + val height = pressedStateInnerBitmap.bounds.height() / 2 + defaultStateInnerBitmap.setBounds( + x - width, + y - height, + x + width, + y + height + ) + pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setOpacity(value: Int) { + opacity = value + + defaultStateInnerBitmap.alpha = value + pressedStateInnerBitmap.alpha = value + + if (trackId == -1) { + outerBitmap.alpha = value + boundsBoxBitmap.alpha = 0 + } else { + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = value + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt new file mode 100644 index 000000000..97eef40d2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +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 com.google.android.material.color.MaterialColors +import com.google.android.material.transition.MaterialFadeThrough +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel + +class GamesFragment : Fragment() { + private var _binding: FragmentGamesBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamesBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) + + binding.gridGames.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.swipeRefresh.apply { + // Add swipe down to refresh gesture + setOnRefreshListener { + gamesViewModel.reloadGames(false) + } + + // Set theme color to the refresh animation's background + setProgressBackgroundColorSchemeColor( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorPrimary + ) + ) + setColorSchemeColors( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorOnPrimary + ) + ) + + // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn + post { + if (_binding == null) { + return@post + } + binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!! + } + } + + gamesViewModel.apply { + // Watch for when we get updates to any of our games lists + isReloading.observe(viewLifecycleOwner) { isReloading -> + binding.swipeRefresh.isRefreshing = isReloading + } + games.observe(viewLifecycleOwner) { + (binding.gridGames.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noticeText.visibility = View.VISIBLE + } else { + binding.noticeText.visibility = View.GONE + } + } + shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData -> + if (shouldSwapData) { + (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!) + gamesViewModel.setShouldSwapData(false) + } + } + + // Check if the user reselected the games menu item and then scroll to top of the list + shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll -> + if (shouldScroll) { + scrollToTop() + gamesViewModel.setShouldScrollToTop(false) + } + } + } + + setInsets() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun scrollToTop() { + if (_binding != null) { + binding.gridGames.smoothScrollToPosition(0) + } + } + + 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_large) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + binding.gridGames.updatePadding( + top = barInsets.top + extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + + binding.swipeRefresh.setProgressViewEndTarget( + false, + barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) + ) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + mlpSwipe.leftMargin = leftInsets + spacingNavigationRail + mlpSwipe.rightMargin = rightInsets + } else { + mlpSwipe.leftMargin = leftInsets + mlpSwipe.rightMargin = rightInsets + spacingNavigationRail + } + binding.swipeRefresh.layoutParams = mlpSwipe + + binding.noticeText.updatePadding(bottom = spacingNavigation) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt new file mode 100644 index 000000000..124f62f08 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -0,0 +1,470 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui.main + +import android.content.Intent +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.lifecycle.lifecycleScope +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.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationBarView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.databinding.ActivityMainBinding +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.* +import java.io.File +import java.io.FilenameFilter +import java.io.IOException + +class MainActivity : AppCompatActivity(), ThemeProvider { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + private val settingsViewModel: SettingsViewModel by viewModels() + + override var themeId: Int = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } + + settingsViewModel.settings.loadSettings() + + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + 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 -> SettingsActivity.launch( + this, + SettingsFile.FILE_NAME_CONFIG, + "" + ) + } + } + + // Prevents navigation from being drawn for a short time on recreation if set to hidden + if (!homeViewModel.navigationVisible.value?.first!!) { + binding.navigationView.visibility = View.INVISIBLE + binding.statusBarShade.visibility = View.INVISIBLE + } + + homeViewModel.navigationVisible.observe(this) { + showNavigation(it.first, it.second) + } + homeViewModel.statusBarShadeVisible.observe(this) { visible -> + showStatusBarShade(visible) + } + + // Dismiss previous notifications (should not happen unless a crash occurred) + EmulationActivity.stopForegroundService(this) + + setInsets() + } + + 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) { + if (visible) { + binding.navigationView.visibility = View.VISIBLE + } else { + binding.navigationView.visibility = View.INVISIBLE + } + return + } + + val smallLayout = resources.getBoolean(R.bool.small_layout) + binding.navigationView.animate().apply { + if (visible) { + binding.navigationView.visibility = View.VISIBLE + 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.visibility = View.INVISIBLE + } + }.start() + } + + private fun showStatusBarShade(visible: Boolean) { + binding.statusBarShade.animate().apply { + if (visible) { + binding.statusBarShade.visibility = View.VISIBLE + 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.visibility = View.INVISIBLE + } + }.start() + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + override fun onDestroy() { + EmulationActivity.stopForegroundService(this) + super.onDestroy() + } + + 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) + return@registerForActivityResult + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() + + Toast.makeText( + applicationContext, + R.string.games_dir_selected, + Toast.LENGTH_LONG + ).show() + + gamesViewModel.reloadGames(true) + } + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + if (!FileUtil.hasExtension(result.toString(), "keys")) { + MessageDialogFragment.newInstance( + R.string.reading_keys_failure, + R.string.install_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( + applicationContext, + result, + dstPath, + "prod.keys" + ) + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + gamesViewModel.reloadGames(true) + } else { + MessageDialogFragment.newInstance( + R.string.invalid_keys_error, + R.string.install_keys_failure_description, + R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + } + + val getFirmware = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + val inputZip = contentResolver.openInputStream(result) + if (inputZip == null) { + Toast.makeText( + applicationContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + return@registerForActivityResult + } + + val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } + + val firmwarePath = + File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") + val cacheFirmwareDir = File("${cacheDir.path}/registered/") + + val task: () -> Any = { + var messageToShow: Any + try { + FileUtil.unzip(inputZip, cacheFirmwareDir) + val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 + val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 + messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { + MessageDialogFragment.newInstance( + R.string.firmware_installed_failure, + R.string.firmware_installed_failure_description + ) + } else { + firmwarePath.deleteRecursively() + cacheFirmwareDir.copyRecursively(firmwarePath, true) + getString(R.string.save_file_imported_success) + } + } catch (e: Exception) { + messageToShow = getString(R.string.fatal_error) + } finally { + cacheFirmwareDir.deleteRecursively() + } + messageToShow + } + + IndeterminateProgressDialogFragment.newInstance( + this, + R.string.firmware_installing, + task + ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG) + } + + val getAmiiboKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + if (!FileUtil.hasExtension(result.toString(), "bin")) { + MessageDialogFragment.newInstance( + R.string.reading_keys_failure, + R.string.install_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( + applicationContext, + result, + dstPath, + "key_retail.bin" + ) + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + } else { + MessageDialogFragment.newInstance( + R.string.invalid_keys_error, + R.string.install_keys_failure_description, + R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + } + + val getDriver = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + val takeFlags = + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission( + result, + takeFlags + ) + + val progressBinding = DialogProgressBarBinding.inflate(layoutInflater) + progressBinding.progressBar.isIndeterminate = true + val installationDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.installing_driver) + .setView(progressBinding.root) + .show() + + lifecycleScope.launch { + withContext(Dispatchers.IO) { + // Ignore file exceptions when a user selects an invalid zip + try { + GpuDriverHelper.installCustomDriver(applicationContext, result) + } catch (_: IOException) { + } + + withContext(Dispatchers.Main) { + installationDialog.dismiss() + + val driverName = GpuDriverHelper.customDriverName + if (driverName != null) { + Toast.makeText( + applicationContext, + getString( + R.string.select_gpu_driver_install_success, + driverName + ), + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + applicationContext, + R.string.select_gpu_driver_error, + Toast.LENGTH_LONG + ).show() + } + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt new file mode 100644 index 000000000..511a6e4fa --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt new file mode 100644 index 000000000..9cfda74ee --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +class BiMap<K, V> { + private val forward: MutableMap<K, V> = HashMap() + private val backward: MutableMap<V, K> = HashMap() + + @Synchronized + fun add(key: K, value: V) { + forward[key] = value + backward[value] = key + } + + @Synchronized + fun getForward(key: K): V? { + return forward[key] + } + + @Synchronized + fun getBackward(key: V): K? { + return backward[key] + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt new file mode 100644 index 000000000..791cea904 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/** + * Some controllers have incorrect mappings. This class has special-case fixes for them. + */ +class ControllerMappingHelper { + /** + * Some controllers report extra button presses that can be ignored. + */ + fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean { + return if (isDualShock4(inputDevice)) { + // The two analog triggers generate analog motion events as well as a keycode. + // We always prefer to use the analog values, so throw away the button press + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2 + } else false + } + + /** + * Scale an axis to be zero-centered with a proper range. + */ + fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float { + if (isDualShock4(inputDevice)) { + // Android doesn't have correct mappings for this controller's triggers. It reports them + // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0] + // Scale them to properly zero-centered with a range of [0.0, 1.0]. + if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) { + return (value + 1) / 2.0f + } + } else if (isXboxOneWireless(inputDevice)) { + // Same as the DualShock 4, the mappings are missing. + if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) { + return (value + 1) / 2.0f + } + if (axis == MotionEvent.AXIS_GENERIC_1) { + // This axis is stuck at ~.5. Ignore it. + return 0.0f + } + } else if (isMogaPro2Hid(inputDevice)) { + // This controller has a broken axis that reports a constant value. Ignore it. + if (axis == MotionEvent.AXIS_GENERIC_1) { + return 0.0f + } + } + return value + } + + // Sony DualShock 4 controller + private fun isDualShock4(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc + } + + // Microsoft Xbox One controller + private fun isXboxOneWireless(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0 + } + + // Moga Pro 2 HID + private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt new file mode 100644 index 000000000..36c479e6c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.IOException + +object DirectoryInitialization { + private var userPath: String? = null + + var areDirectoriesReady: Boolean = false + + fun start(context: Context) { + if (!areDirectoriesReady) { + initializeInternalStorage(context) + NativeLibrary.initializeEmulation() + areDirectoriesReady = true + } + } + + val userDirectory: String? + get() { + check(areDirectoriesReady) { "Directory initialization is not ready!" } + return userPath + } + + private fun initializeInternalStorage(context: Context) { + try { + userPath = context.getExternalFilesDir(null)!!.canonicalPath + NativeLibrary.setAppDirectory(userPath!!) + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt new file mode 100644 index 000000000..cc8ea6b9d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import java.io.File +import java.util.* + +class DocumentsTree { + private var root: DocumentsNode? = null + + fun setRoot(rootUri: Uri?) { + root = null + root = DocumentsNode() + root!!.uri = rootUri + root!!.isDirectory = true + } + + fun openContentUri(filepath: String, openMode: String?): Int { + val node = resolvePath(filepath) ?: return -1 + return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode) + } + + fun getFileSize(filepath: String): Long { + val node = resolvePath(filepath) + return if (node == null || node.isDirectory) { + 0 + } else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString()) + } + + fun exists(filepath: String): Boolean { + return resolvePath(filepath) != null + } + + private fun resolvePath(filepath: String): DocumentsNode? { + val tokens = StringTokenizer(filepath, File.separator, false) + var iterator = root + while (tokens.hasMoreTokens()) { + val token = tokens.nextToken() + if (token.isEmpty()) continue + iterator = find(iterator, token) + if (iterator == null) return null + } + return iterator + } + + private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? { + if (parent!!.isDirectory && !parent.loaded) { + structTree(parent) + } + return parent.children[filename] + } + + /** + * Construct current level directory tree + * @param parent parent node of this level + */ + private fun structTree(parent: DocumentsNode) { + val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!) + for (document in documents) { + val node = DocumentsNode(document) + node.parent = parent + parent.children[node.name] = node + } + parent.loaded = true + } + + private class DocumentsNode { + var parent: DocumentsNode? = null + val children: MutableMap<String?, DocumentsNode> = HashMap() + var name: String? = null + var uri: Uri? = null + var loaded = false + var isDirectory = false + + constructor() + constructor(document: MinimalDocumentFile) { + name = document.filename + uri = document.uri + isDirectory = document.isDirectory + loaded = !isDirectory + } + + private constructor(document: DocumentFile, isCreateDir: Boolean) { + name = document.name + uri = document.uri + isDirectory = isCreateDir + loaded = true + } + + private fun rename(name: String) { + if (parent == null) { + return + } + parent!!.children.remove(this.name) + this.name = name + parent!!.children[name] = this + } + } + + companion object { + fun isNativePath(path: String): Boolean { + return if (path.isNotEmpty()) { + path[0] == '/' + } else false + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt new file mode 100644 index 000000000..e1e7a59d7 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import androidx.preference.PreferenceManager +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings + +object EmulationMenuSettings { + private val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // These must match what is defined in src/core/settings.h + const val LayoutOption_Default = 0 + const val LayoutOption_SingleScreen = 1 + const val LayoutOption_LargeScreen = 2 + const val LayoutOption_SideScreen = 3 + const val LayoutOption_MobilePortrait = 4 + const val LayoutOption_MobileLandscape = 5 + + var joystickRelCenter: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value) + .apply() + } + var dpadSlide: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value) + .apply() + } + var hapticFeedback: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value) + .apply() + } + + var landscapeScreenLayout: Int + get() = preferences.getInt( + Settings.PREF_MENU_SETTINGS_LANDSCAPE, + LayoutOption_MobileLandscape + ) + set(value) { + preferences.edit() + .putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value) + .apply() + } + var showFps: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value) + .apply() + } + var showOverlay: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value) + .apply() + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt new file mode 100644 index 000000000..593dad8d3 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.URLDecoder +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +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" + + /** + * 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(context: Context?, 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 context Application context + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + fun createDir(context: Context?, 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 context Application context + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(context: Context, 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 DoucmentFile.listFiles + * @param context Application context + * @param uri Directory uri. + * @return CheapDocument lists. + */ + fun listFiles(context: Context, uri: Uri): Array<MinimalDocumentFile> { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var c: Cursor? = null + val results: MutableList<MinimalDocumentFile> = ArrayList() + try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = resolver.query(childrenUri, columns, null, null, null) + while (c!!.moveToNext()) { + val documentId = c.getString(0) + val documentName = c.getString(1) + val documentMimeType = c.getString(2) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) + val document = MinimalDocumentFile(documentName, documentMimeType, documentUri) + results.add(document) + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list file error: " + e.message) + } finally { + closeQuietly(c) + } + return results.toTypedArray() + } + + /** + * Check whether given path exists. + * @param path Native content uri path + * @return bool + */ + fun exists(context: Context, path: String?): 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) { + 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(context: Context, 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 path content uri path + * @return String display name + */ + fun getFilename(context: Context, path: String): String { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + var filename = "" + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, 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(context: Context, path: String): Array<String> { + val uri = Uri.parse(path) + val files: MutableList<String> = ArrayList() + for (file in listFiles(context, 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(context: Context, 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 + } + + fun copyUriToInternalStorage( + context: Context, + sourceUri: Uri?, + destinationParentPath: String, + destinationFilename: String + ): Boolean { + var input: InputStream? = null + var output: FileOutputStream? = null + try { + input = context.contentResolver.openInputStream(sourceUri!!) + output = FileOutputStream("$destinationParentPath/$destinationFilename") + val buffer = ByteArray(1024) + var len: Int + while (input!!.read(buffer).also { len = it } != -1) { + output.write(buffer, 0, len) + } + output.flush() + return true + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot copy file, error: " + e.message) + } finally { + if (input != null) { + try { + input.close() + } catch (e: IOException) { + Log.error("[FileUtil]: Cannot close input file, error: " + e.message) + } + } + if (output != null) { + try { + output.close() + } catch (e: IOException) { + Log.error("[FileUtil]: Cannot close output file, error: " + e.message) + } + } + } + return false + } + + /** + * Extracts the given zip file into the given directory. + * @exception IOException if the file was being created outside of the target directory + */ + @Throws(SecurityException::class) + fun unzip(zipStream: InputStream, destDir: File): Boolean { + ZipInputStream(BufferedInputStream(zipStream)).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + val entryName = entry.name + val entryFile = File(destDir, entryName) + if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Entry is outside of the target dir: " + entryFile.name) + } + if (entry.isDirectory) { + entryFile.mkdirs() + } else { + entryFile.parentFile?.mkdirs() + entryFile.createNewFile() + entryFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + } + } + + return true + } + + 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 hasExtension(path: String, extension: String): Boolean { + return path.substring(path.lastIndexOf(".") + 1).contains(extension) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt new file mode 100644 index 000000000..dc9b7c744 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity + +/** + * A service that shows a permanent notification in the background to avoid the app getting + * cleared from memory by the system. + */ +class ForegroundService : Service() { + companion object { + const val EMULATION_RUNNING_NOTIFICATION = 0x1000 + + const val ACTION_STOP = "stop" + } + + private fun showRunningNotification() { + // Intent is used to resume emulation if the notification is clicked + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, EmulationActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + val builder = + NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.emulation_notification_running)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setVibrate(null) + .setSound(null) + .setContentIntent(contentIntent) + startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onCreate() { + showRunningNotification() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + return START_NOT_STICKY; + } + if (intent.action == ACTION_STOP) { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelfResult(startId) + } + return START_STICKY + } + + override fun onDestroy() { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt new file mode 100644 index 000000000..ba6b5783e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.Game +import java.util.* + +object GameHelper { + const val KEY_GAME_PATH = "game_path" + const val KEY_GAMES = "Games" + + private lateinit var preferences: SharedPreferences + + fun getGames(): List<Game> { + val games = mutableListOf<Game>() + val context = YuzuApplication.appContext + val gamesDir = + PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "") + val gamesUri = Uri.parse(gamesDir) + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + val children = FileUtil.listFiles(context, gamesUri) + for (file in children) { + if (!file.isDirectory) { + val filename = file.uri.toString() + val extensionStart = filename.lastIndexOf('.') + if (extensionStart > 0) { + val fileExtension = filename.substring(extensionStart) + + // Check that the file has an extension we care about before trying to read out of it. + if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) { + games.add(getGame(filename)) + } + } + } + } + + // Cache list of games found on disk + val serializedGames = mutableSetOf<String>() + games.forEach { + serializedGames.add(Json.encodeToString(it)) + } + preferences.edit() + .remove(KEY_GAMES) + .putStringSet(KEY_GAMES, serializedGames) + .apply() + + return games.toList() + } + + private fun getGame(filePath: String): Game { + var name = NativeLibrary.getTitle(filePath) + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = filePath.substring(filePath.lastIndexOf("/") + 1) + } + var gameId = NativeLibrary.getGameId(filePath) + + // If the game's ID field is empty, use the filename without extension. + if (gameId.isEmpty()) { + gameId = filePath.substring( + filePath.lastIndexOf("/") + 1, + filePath.lastIndexOf(".") + ) + } + + val newGame = Game( + name, + NativeLibrary.getDescription(filePath).replace("\n", " "), + NativeLibrary.getRegions(filePath), + filePath, + gameId, + NativeLibrary.getCompany(filePath) + ) + + 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/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt new file mode 100644 index 000000000..528011d7f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import android.net.Uri +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipInputStream + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip" + private var fileRedirectionPath: String? = null + private var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + @Throws(IOException::class) + private fun unzip(zipFilePath: String, destDir: String) { + val dir = File(destDir) + + // Create output directory if it doesn't exist + if (!dir.exists()) dir.mkdirs() + + // Unpack the files. + val inputStream = FileInputStream(zipFilePath) + val zis = ZipInputStream(BufferedInputStream(inputStream)) + val buffer = ByteArray(1024) + var ze = zis.nextEntry + while (ze != null) { + val newFile = File(destDir, ze.name) + val canonicalPath = newFile.canonicalPath + if (!canonicalPath.startsWith(destDir + ze.name)) { + throw SecurityException("Zip file attempted path traversal! " + ze.name) + } + + newFile.parentFile!!.mkdirs() + val fos = FileOutputStream(newFile) + var len: Int + while (zis.read(buffer).also { len = it } > 0) { + fos.write(buffer, 0, len) + } + fos.close() + zis.closeEntry() + ze = zis.nextEntry + } + zis.closeEntry() + } + + fun initializeDriverParameters(context: Context) { + try { + // Initialize the file redirection directory. + fileRedirectionPath = + context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = context.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + customDriverLibraryName, + fileRedirectionPath + ) + } + + fun installDefaultDriver(context: Context) { + // Removing the installed driver will result in the backend using the default system driver. + val driverInstallationDir = File(driverInstallationPath!!) + deleteRecursive(driverInstallationDir) + initializeDriverParameters(context) + } + + fun installCustomDriver(context: Context, driverPathUri: Uri?) { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver(context) + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI into our private storage. + copyUriToInternalStorage( + context, + driverPathUri, + driverInstallationPath!!, + DRIVER_INTERNAL_FILENAME + ) + + // Unzip the driver. + try { + unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!) + } catch (e: SecurityException) { + return + } + + // Initialize the driver parameters. + initializeDriverParameters(context) + } + + // Parse the custom driver metadata to retrieve the name. + val customDriverName: String? + get() { + val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) + return metadata.name + } + + // Parse the custom driver metadata to retrieve the library name. + private val customDriverLibraryName: String? + get() { + // Parse the custom driver metadata to retrieve the library name. + val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) + return metadata.libraryName + } + + private 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() + } + } + + private fun deleteRecursive(fileOrDirectory: File) { + if (fileOrDirectory.isDirectory) { + for (child in fileOrDirectory.listFiles()!!) { + deleteRecursive(child) + } + } + fileOrDirectory.delete() + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt new file mode 100644 index 000000000..70bdb4097 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +class GpuDriverMetadata(metadataFilePath: String) { + var name: String? = null + var description: String? = null + var author: String? = null + var vendor: String? = null + var driverVersion: String? = null + var minApi = 0 + var libraryName: String? = null + + init { + try { + val json = JSONObject(getStringFromFile(metadataFilePath)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + driverVersion = json.getString("driverVersion") + minApi = json.getInt("minApi") + libraryName = json.getString("libraryName") + } catch (e: JSONException) { + // JSON is malformed, ignore and treat as unsupported metadata. + } catch (e: IOException) { + // File is inaccessible, ignore and treat as unsupported metadata. + } + } + + companion object { + @Throws(IOException::class) + private fun getStringFromFile(filePath: String): String { + val path = Paths.get(filePath) + val bytes = Files.readAllBytes(path) + return String(bytes, StandardCharsets.UTF_8) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt new file mode 100644 index 000000000..24e999b29 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.view.KeyEvent +import android.view.MotionEvent +import org.yuzu.yuzu_emu.NativeLibrary +import kotlin.math.sqrt + +class InputHandler { + fun initialize() { + // Connect first controller + NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device)) + } + + fun dispatchKeyEvent(event: KeyEvent): Boolean { + val button: Int = when (event.device.vendorId) { + 0x045E -> getInputXboxButtonKey(event.keyCode) + 0x054C -> getInputDS5ButtonKey(event.keyCode) + 0x057E -> getInputJoyconButtonKey(event.keyCode) + 0x1532 -> getInputRazerButtonKey(event.keyCode) + else -> getInputGenericButtonKey(event.keyCode) + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED + else -> return false + } + + // Ignore invalid buttons + if (button < 0) { + return false + } + + return NativeLibrary.onGamePadButtonEvent( + getPlayerNumber(event.device.controllerNumber), + button, + action + ) + } + + fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val device = event.device + // Check every axis input available on the controller + for (range in device.motionRanges) { + val axis = range.axis + when (device.vendorId) { + 0x045E -> setGenericAxisInput(event, axis) + 0x054C -> setGenericAxisInput(event, axis) + 0x057E -> setJoyconAxisInput(event, axis) + 0x1532 -> setRazerAxisInput(event, axis) + else -> setGenericAxisInput(event, axis) + } + } + + return true + } + + private fun getPlayerNumber(index: Int): Int { + // TODO: Joycons are handled as different controllers. Find a way to merge them. + return when (index) { + 2 -> NativeLibrary.Player2Device + 3 -> NativeLibrary.Player3Device + 4 -> NativeLibrary.Player4Device + 5 -> NativeLibrary.Player5Device + 6 -> NativeLibrary.Player6Device + 7 -> NativeLibrary.Player7Device + 8 -> NativeLibrary.Player8Device + else -> if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device + } + } + + private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) { + // Calculate vector size + val r2 = xAxis * xAxis + yAxis * yAxis + var r = sqrt(r2.toDouble()).toFloat() + + // Adjust range of joystick + val deadzone = 0.15f + var x = xAxis + var y = yAxis + + if (r > deadzone) { + val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone) + x *= deadzoneFactor + y *= deadzoneFactor + r *= deadzoneFactor + } else { + x = 0.0f + y = 0.0f + } + + // Normalize joystick + if (r > 1.0f) { + x /= r + y /= r + } + + NativeLibrary.onGamePadJoystickEvent( + playerNumber, + index, + x, + -y + ) + } + + private fun getAxisToButton(axis: Float): Int { + return if (axis > 0.5f) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED + } + + private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) { + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_UP, + getAxisToButton(-yAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_DOWN, + getAxisToButton(yAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_LEFT, + getAxisToButton(-xAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_RIGHT, + getAxisToButton(xAxis) + ) + } + + private fun getInputDS5ButtonKey(key: Int): Int { + // The missing ds5 buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputJoyconButtonKey(key: Int): Int { + // Joycon support is half dead. A lot of buttons can't be mapped + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL + KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputXboxButtonKey(key: Int): Int { + // The missing xbox buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputRazerButtonKey(key: Int): Int { + // The missing xbox buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputGenericButtonKey(key: Int): Int { + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL + KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun setGenericAxisInput(event: MotionEvent, axis: Int) { + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_RX), + event.getAxisValue(MotionEvent.AXIS_RY) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_LTRIGGER -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER)) + ) + MotionEvent.AXIS_BRAKE -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) + ) + MotionEvent.AXIS_RTRIGGER -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER)) + ) + MotionEvent.AXIS_GAS -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) + ) + MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> + setAxisDpadState( + playerNumber, + event.getAxisValue(MotionEvent.AXIS_HAT_X), + event.getAxisValue(MotionEvent.AXIS_HAT_Y) + ) + } + } + + + private fun setJoyconAxisInput(event: MotionEvent, axis: Int) { + // Joycon support is half dead. Right joystick doesn't work + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_RX), + event.getAxisValue(MotionEvent.AXIS_RY) + ) + } + } + + private fun setRazerAxisInput(event: MotionEvent, axis: Int) { + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_BRAKE -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) + ) + MotionEvent.AXIS_GAS -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) + ) + MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> + setAxisDpadState( + playerNumber, + event.getAxisValue(MotionEvent.AXIS_HAT_X), + event.getAxisValue(MotionEvent.AXIS_HAT_Y) + ) + } + } + + +}
\ No newline at end of file diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt new file mode 100644 index 000000000..19c53c481 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Rect + +object InsetsHelper { + const val THREE_BUTTON_NAVIGATION = 0 + const val TWO_BUTTON_NAVIGATION = 1 + const val GESTURE_NAVIGATION = 2 + + @SuppressLint("DiscouragedApi") + fun getSystemGestureType(context: Context): Int { + val resources = context.resources + val resourceId = + resources.getIdentifier("config_navBarInteractionMode", "integer", "android") + return if (resourceId != 0) { + resources.getInteger(resourceId) + } else 0 + } + + fun getBottomPaddingRequired(activity: Activity): Int { + val visibleFrame = Rect() + activity.window.decorView.getWindowVisibleDisplayFrame(visibleFrame) + return visibleFrame.bottom - visibleFrame.top - activity.resources.displayMetrics.heightPixels + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt new file mode 100644 index 000000000..a193e82a4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.util.Log +import org.yuzu.yuzu_emu.BuildConfig + +/** + * Contains methods that call through to [android.util.Log], but + * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log + * levels in release builds. + */ +object Log { + private const val TAG = "Yuzu Frontend" + + fun verbose(message: String) { + if (BuildConfig.DEBUG) { + Log.v(TAG, message) + } + } + + fun debug(message: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, message) + } + } + + fun info(message: String) { + Log.i(TAG, message) + } + + fun warning(message: String) { + Log.w(TAG, message) + } + + fun error(message: String) { + Log.e(TAG, message) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt new file mode 100644 index 000000000..344dd8a0a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentFilter +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.NfcA +import android.os.Build +import android.os.Handler +import android.os.Looper +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.IOException + +class NfcReader(private val activity: Activity) { + private var nfcAdapter: NfcAdapter? = null + private var pendingIntent: PendingIntent? = null + + fun initialize() { + nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return + + pendingIntent = PendingIntent.getActivity( + activity, + 0, Intent(activity, activity.javaClass), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + else PendingIntent.FLAG_UPDATE_CURRENT + ) + + val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + tagDetected.addCategory(Intent.CATEGORY_DEFAULT) + } + + fun startScanning() { + nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null) + } + + fun stopScanning() { + nfcAdapter?.disableForegroundDispatch(activity) + } + + fun onNewIntent(intent: Intent) { + val action = intent.action + if (NfcAdapter.ACTION_TAG_DISCOVERED != action + && NfcAdapter.ACTION_TECH_DISCOVERED != action + && NfcAdapter.ACTION_NDEF_DISCOVERED != action + ) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return + readTagData(tag) + return + } + + val tag = + intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return + readTagData(tag) + } + + private fun readTagData(tag: Tag) { + if (!tag.techList.contains("android.nfc.tech.NfcA")) { + return + } + + val amiibo = NfcA.get(tag) ?: return + amiibo.connect() + + val tagData = ntag215ReadAll(amiibo) ?: return + NativeLibrary.onReadNfcTag(tagData) + + nfcAdapter?.ignore( + tag, + 1000, + { NativeLibrary.onRemoveNfcTag() }, + Handler(Looper.getMainLooper()) + ) + } + + private fun ntag215ReadAll(amiibo: NfcA): ByteArray? { + val bufferSize = amiibo.maxTransceiveLength; + val tagSize = 0x21C + val pageSize = 4 + val lastPage = tagSize / pageSize - 1 + val tagData = ByteArray(tagSize) + + // We need to read the ntag in steps otherwise we overflow the buffer + for (i in 0..tagSize step bufferSize - 1) { + val dataStart = i / pageSize + var dataEnd = (i + bufferSize) / pageSize + + if (dataEnd > lastPage) { + dataEnd = lastPage + } + + try { + val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1) + System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize) + } catch (e: IOException) { + return null; + } + } + return tagData + } + + private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x30.toByte(), + (page and 0xFF).toByte() + ) + ) + } + + private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x3A.toByte(), + (start and 0xFF).toByte(), + (end and 0xFF).toByte() + ) + ) + } + + private fun ntag215PWrite( + amiibo: NfcA, + page: Int, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0xA2.toByte(), + (page and 0xFF).toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } + + private fun ntag215PwdAuth( + amiibo: NfcA, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x1B.toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt new file mode 100644 index 000000000..87ee7f2e6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +object SerializableHelper { + inline fun <reified T : Serializable> Bundle.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getSerializable(key, T::class.java) + else + getSerializable(key) as? T + } + + inline fun <reified T : Serializable> Intent.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getSerializableExtra(key, T::class.java) + else + getSerializableExtra(key) as? T + } + + inline fun <reified T : Parcelable> Bundle.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getParcelable(key, T::class.java) + else + getParcelable(key) as? T + } + + inline fun <reified T : Parcelable> Intent.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + getParcelableExtra(key, T::class.java) + else + getParcelableExtra(key) as? T + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt new file mode 100644 index 000000000..e55767c0f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.content.res.Configuration +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.preference.PreferenceManager +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.ui.main.ThemeProvider +import kotlin.math.roundToInt + +object ThemeHelper { + const val SYSTEM_BAR_ALPHA = 0.9f + + private const val DEFAULT = 0 + private const val MATERIAL_YOU = 1 + + fun setTheme(activity: AppCompatActivity) { + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + setThemeMode(activity) + when (preferences.getInt(Settings.PREF_THEME, 0)) { + DEFAULT -> activity.setTheme(R.style.Theme_Yuzu_Main) + MATERIAL_YOU -> activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou) + } + + // Using a specific night mode check because this could apply incorrectly when using the + // light app mode, dark system mode, and black backgrounds. Launching the settings activity + // will then show light mode colors/navigation bars but with black backgrounds. + if (preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + && isNightMode(activity) + ) { + activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark) + } + } + + @ColorInt + fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int { + return Color.argb( + (alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color), + Color.green(color), Color.blue(color) + ) + } + + fun setCorrectTheme(activity: AppCompatActivity) { + val currentTheme = (activity as ThemeProvider).themeId + setTheme(activity) + if (currentTheme != (activity as ThemeProvider).themeId) { + activity.recreate() + } + } + + fun setThemeMode(activity: AppCompatActivity) { + val themeMode = PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) + .getInt(Settings.PREF_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + activity.delegate.localNightMode = themeMode + val windowController = WindowCompat.getInsetsController( + activity.window, + activity.window.decorView + ) + when (themeMode) { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) { + false -> setLightModeSystemBars(windowController) + true -> setDarkModeSystemBars(windowController) + } + AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController) + AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController) + } + } + + private fun isNightMode(activity: AppCompatActivity): Boolean { + return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + } + + private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = true + windowController.isAppearanceLightNavigationBars = true + } + + private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = false + windowController.isAppearanceLightNavigationBars = false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt new file mode 100644 index 000000000..c8ef8c1fd --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.views + +import android.content.Context +import android.util.AttributeSet +import android.util.Rational +import android.view.SurfaceView +import kotlin.math.roundToInt + +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 + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + if (aspectRatio != 0f) { + val newWidth: Int + val newHeight: Int + if (height * aspectRatio < width) { + newWidth = (height * aspectRatio).roundToInt() + newHeight = height + } else { + newWidth = width + newHeight = (width / aspectRatio).roundToInt() + } + setMeasuredDimension(newWidth, newHeight) + } else { + setMeasuredDimension(width, height) + } + } +} |