// 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.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.Configuration import android.graphics.Rect import android.graphics.drawable.Icon import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.os.Build import android.os.Bundle import android.util.Rational import android.view.InputDevice import android.view.KeyEvent import android.view.MotionEvent import android.view.Surface import android.view.View import android.view.inputmethod.InputMethodManager import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.navigation.fragment.NavHostFragment import org.yuzu.yuzu_emu.NativeLibrary import org.yuzu.yuzu_emu.R import org.yuzu.yuzu_emu.databinding.ActivityEmulationBinding 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.SettingsViewModel import org.yuzu.yuzu_emu.model.Game import org.yuzu.yuzu_emu.utils.ControllerMappingHelper 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.ThemeHelper import kotlin.math.roundToInt class EmulationActivity : AppCompatActivity(), SensorEventListener { private lateinit var binding: ActivityEmulationBinding private var controllerMappingHelper: ControllerMappingHelper? = null var isActivityRecreated = false 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 val actionPause = "ACTION_EMULATOR_PAUSE" private val actionPlay = "ACTION_EMULATOR_PLAY" 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) binding = ActivityEmulationBinding.inflate(layoutInflater) setContentView(binding.root) val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment val navController = navHostFragment.navController navController .setGraph(R.navigation.emulation_navigation, intent.extras) isActivityRecreated = savedInstanceState != null controllerMappingHelper = ControllerMappingHelper() // Set these options now so that the SurfaceView the game renders into is the right size. enableFullscreenImmersive() window.decorView.setBackgroundColor(getColor(android.R.color.black)) nfcReader = NfcReader(this) nfcReader.initialize() inputHandler = InputHandler() inputHandler.initialize() // 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() buildPictureInPictureParams() } override fun onPause() { super.onPause() nfcReader.stopScanning() stopMotionSensorListener() } override fun onUserLeaveHint() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { if (BooleanSetting.PICTURE_IN_PICTURE.boolean && !isInPictureInPictureMode) { val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() enterPictureInPictureMode(pictureInPictureParamsBuilder.build()) } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) nfcReader.onNewIntent(intent) } 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 enableFullscreenImmersive() { WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).let { controller -> controller.hide(WindowInsetsCompat.Type.systemBars()) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder(): PictureInPictureParams.Builder { val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.int) { 0 -> Rational(16, 9) 1 -> Rational(4, 3) 2 -> Rational(21, 9) 3 -> Rational(16, 10) else -> null // Best fit } return this.apply { aspectRatio?.let { setAspectRatio(it) } } } private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder(): PictureInPictureParams.Builder { val pictureInPictureActions: MutableList = mutableListOf() val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE if (NativeLibrary.isPaused()) { val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play) val playPendingIntent = PendingIntent.getBroadcast( this@EmulationActivity, R.drawable.ic_pip_play, Intent(actionPlay), pendingFlags ) val playRemoteAction = RemoteAction( playIcon, getString(R.string.play), getString(R.string.play), playPendingIntent ) pictureInPictureActions.add(playRemoteAction) } else { val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause) val pausePendingIntent = PendingIntent.getBroadcast( this@EmulationActivity, R.drawable.ic_pip_pause, Intent(actionPause), pendingFlags ) val pauseRemoteAction = RemoteAction( pauseIcon, getString(R.string.pause), getString(R.string.pause), pausePendingIntent ) pictureInPictureActions.add(pauseRemoteAction) } return this.apply { setActions(pictureInPictureActions) } } fun buildPictureInPictureParams() { val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { pictureInPictureParamsBuilder.setAutoEnterEnabled(BooleanSetting.PICTURE_IN_PICTURE.boolean) } setPictureInPictureParams(pictureInPictureParamsBuilder.build()) } private var pictureInPictureReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { if (intent.action == actionPlay) { if (NativeLibrary.isPaused()) NativeLibrary.unPauseEmulation() } else if (intent.action == actionPause) { if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation() } buildPictureInPictureParams() } } override fun onPictureInPictureModeChanged( isInPictureInPictureMode: Boolean, newConfig: Configuration ) { super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) if (isInPictureInPictureMode) { IntentFilter().apply { addAction(actionPause) addAction(actionPlay) }.also { registerReceiver(pictureInPictureReceiver, it) } } else { try { unregisterReceiver(pictureInPictureReceiver) } catch (ignored : Exception) { } } } 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()) } } }