diff --git a/src/android/sudachi/proguard-rules.pro b/src/android/sudachi/proguard-rules.pro
new file mode 100644
index 0000000..3c6e455
--- /dev/null
+++ b/src/android/sudachi/proguard-rules.pro
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2023 sudachi Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# To get usable stack traces
+-dontobfuscate
+
+# Prevents crashing when using Wini
+-keep class org.ini4j.spi.IniParser
+-keep class org.ini4j.spi.IniBuilder
+-keep class org.ini4j.spi.IniFormatter
+
+# Suppress warnings for R8
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
+-dontwarn java.beans.Introspector
+-dontwarn java.beans.VetoableChangeListener
+-dontwarn java.beans.VetoableChangeSupport
diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml
new file mode 100644
index 0000000..deb8ba5
--- /dev/null
+++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml
new file mode 100644
index 0000000..4ef4728
--- /dev/null
+++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml
new file mode 100644
index 0000000..29d0cfc
--- /dev/null
+++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/sudachi/src/main/AndroidManifest.xml b/src/android/sudachi/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..73638c0
--- /dev/null
+++ b/src/android/sudachi/src/main/AndroidManifest.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt
new file mode 100644
index 0000000..858b6ef
--- /dev/null
+++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt
@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.sudachi.sudachi_emu
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import org.sudachi.sudachi_emu.features.input.NativeInput
+import java.io.File
+import org.sudachi.sudachi_emu.utils.DirectoryInitialization
+import org.sudachi.sudachi_emu.utils.DocumentsTree
+import org.sudachi.sudachi_emu.utils.GpuDriverHelper
+import org.sudachi.sudachi_emu.utils.Log
+
+fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
+
+class SudachiApplication : Application() {
+ private fun createNotificationChannels() {
+ val noticeChannel = NotificationChannel(
+ getString(R.string.notice_notification_channel_id),
+ getString(R.string.notice_notification_channel_name),
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ noticeChannel.description = getString(R.string.notice_notification_channel_description)
+ noticeChannel.setSound(null, null)
+
+ // Register the channel with the system; you can't change the importance
+ // or other notification behaviors after this
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(noticeChannel)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ application = this
+ documentsTree = DocumentsTree()
+ DirectoryInitialization.start()
+ GpuDriverHelper.initializeDriverParameters()
+ NativeInput.reloadInputDevices()
+ NativeLibrary.logDeviceInfo()
+ Log.logDeviceInfo()
+
+ createNotificationChannels()
+ }
+
+ companion object {
+ var documentsTree: DocumentsTree? = null
+ lateinit var application: SudachiApplication
+
+ val appContext: Context
+ get() = application.applicationContext
+ }
+}
diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt
new file mode 100644
index 0000000..59a14ea
--- /dev/null
+++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt
@@ -0,0 +1,509 @@
+// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.sudachi.sudachi_emu.activities
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.app.PictureInPictureParams
+import android.app.RemoteAction
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.graphics.drawable.Icon
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.os.Build
+import android.os.Bundle
+import android.util.Rational
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.Surface
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.navigation.fragment.NavHostFragment
+import androidx.preference.PreferenceManager
+import org.sudachi.sudachi_emu.NativeLibrary
+import org.sudachi.sudachi_emu.R
+import org.sudachi.sudachi_emu.SudachiApplication
+import org.sudachi.sudachi_emu.databinding.ActivityEmulationBinding
+import org.sudachi.sudachi_emu.features.input.NativeInput
+import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting
+import org.sudachi.sudachi_emu.features.settings.model.IntSetting
+import org.sudachi.sudachi_emu.features.settings.model.Settings
+import org.sudachi.sudachi_emu.model.EmulationViewModel
+import org.sudachi.sudachi_emu.model.Game
+import org.sudachi.sudachi_emu.utils.InputHandler
+import org.sudachi.sudachi_emu.utils.Log
+import org.sudachi.sudachi_emu.utils.MemoryUtil
+import org.sudachi.sudachi_emu.utils.NativeConfig
+import org.sudachi.sudachi_emu.utils.NfcReader
+import org.sudachi.sudachi_emu.utils.ParamPackage
+import org.sudachi.sudachi_emu.utils.ThemeHelper
+import java.text.NumberFormat
+import kotlin.math.roundToInt
+
+class EmulationActivity : AppCompatActivity(), SensorEventListener {
+ private lateinit var binding: ActivityEmulationBinding
+
+ var isActivityRecreated = false
+ private lateinit var nfcReader: NfcReader
+
+ private val gyro = FloatArray(3)
+ private val accel = FloatArray(3)
+ private var motionTimestamp: Long = 0
+ private var flipMotionOrientation: Boolean = false
+
+ private val actionPause = "ACTION_EMULATOR_PAUSE"
+ private val actionPlay = "ACTION_EMULATOR_PLAY"
+ private val actionMute = "ACTION_EMULATOR_MUTE"
+ private val actionUnmute = "ACTION_EMULATOR_UNMUTE"
+
+ private val emulationViewModel: EmulationViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ Log.gameLaunched = true
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ InputHandler.updateControllerData()
+ val players = NativeConfig.getInputSettings(true)
+ var hasConfiguredControllers = false
+ players.forEach {
+ if (it.hasMapping()) {
+ hasConfiguredControllers = true
+ }
+ }
+ if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) {
+ var params: ParamPackage? = null
+ for (controller in InputHandler.registeredControllers) {
+ if (controller.get("port", -1) == 0) {
+ params = controller
+ break
+ }
+ }
+
+ if (params != null) {
+ NativeInput.updateMappingsWithDefault(
+ 0,
+ params,
+ params.get("display", getString(R.string.unknown))
+ )
+ NativeConfig.saveGlobalConfig()
+ }
+ }
+
+ binding = ActivityEmulationBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+ navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras)
+
+ isActivityRecreated = savedInstanceState != null
+
+ // Set these options now so that the SurfaceView the game renders into is the right size.
+ enableFullscreenImmersive()
+
+ window.decorView.setBackgroundColor(getColor(android.R.color.black))
+
+ nfcReader = NfcReader(this)
+ nfcReader.initialize()
+
+ val preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext)
+ if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) {
+ if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) {
+ Toast.makeText(
+ this,
+ getString(
+ R.string.device_memory_inadequate,
+ MemoryUtil.getDeviceRAM(),
+ getString(
+ R.string.memory_formatted,
+ NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY),
+ getString(R.string.memory_gigabyte)
+ )
+ ),
+ Toast.LENGTH_LONG
+ ).show()
+ preferences.edit()
+ .putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true)
+ .apply()
+ }
+ }
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ // Special case, we do not support multiline input, dismiss the keyboard.
+ val overlayView: View =
+ this.findViewById(R.id.surface_input_overlay)
+ val im =
+ overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ im.hideSoftInputFromWindow(overlayView.windowToken, 0)
+ } else {
+ val textChar = event.unicodeChar
+ if (textChar == 0) {
+ // No text, button input.
+ NativeLibrary.submitInlineKeyboardInput(keyCode)
+ } else {
+ // Text submitted.
+ NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
+ }
+ }
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ nfcReader.startScanning()
+ startMotionSensorListener()
+ InputHandler.updateControllerData()
+
+ buildPictureInPictureParams()
+ }
+
+ override fun onPause() {
+ super.onPause()
+ nfcReader.stopScanning()
+ stopMotionSensorListener()
+ }
+
+ override fun onUserLeaveHint() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
+ if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) {
+ val pictureInPictureParamsBuilder = PictureInPictureParams.Builder()
+ .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder()
+ enterPictureInPictureMode(pictureInPictureParamsBuilder.build())
+ }
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ nfcReader.onNewIntent(intent)
+ InputHandler.updateControllerData()
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchKeyEvent(event)
+ }
+
+ if (emulationViewModel.drawerOpen.value) {
+ return super.dispatchKeyEvent(event)
+ }
+
+ return InputHandler.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ if (emulationViewModel.drawerOpen.value) {
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ // Don't attempt to do anything if we are disconnecting a device.
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ return true
+ }
+
+ return InputHandler.dispatchGenericMotionEvent(event)
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ val rotation = this.display?.rotation
+ if (rotation == Surface.ROTATION_90) {
+ flipMotionOrientation = true
+ }
+ if (rotation == Surface.ROTATION_270) {
+ flipMotionOrientation = false
+ }
+
+ if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
+ if (flipMotionOrientation) {
+ accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
+ } else {
+ accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
+ }
+ accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
+ }
+ if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
+ // Investigate why sensor value is off by 6x
+ if (flipMotionOrientation) {
+ gyro[0] = -event.values[1] / 6.0f
+ gyro[1] = event.values[0] / 6.0f
+ } else {
+ gyro[0] = event.values[1] / 6.0f
+ gyro[1] = -event.values[0] / 6.0f
+ }
+ gyro[2] = event.values[2] / 6.0f
+ }
+
+ // Only update state on accelerometer data
+ if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
+ return
+ }
+ val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
+ motionTimestamp = event.timestamp
+ NativeInput.onDeviceMotionEvent(
+ NativeInput.Player1Device,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ NativeInput.onDeviceMotionEvent(
+ NativeInput.ConsoleDevice,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
+
+ private fun enableFullscreenImmersive() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ WindowInsetsControllerCompat(window, window.decorView).let { controller ->
+ controller.hide(WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder():
+ PictureInPictureParams.Builder {
+ val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) {
+ 0 -> Rational(16, 9)
+ 1 -> Rational(4, 3)
+ 2 -> Rational(21, 9)
+ 3 -> Rational(16, 10)
+ else -> null // Best fit
+ }
+ return this.apply { aspectRatio?.let { setAspectRatio(it) } }
+ }
+
+ private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder():
+ PictureInPictureParams.Builder {
+ val pictureInPictureActions: MutableList = mutableListOf()
+ val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+
+ if (NativeLibrary.isPaused()) {
+ val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play)
+ val playPendingIntent = PendingIntent.getBroadcast(
+ this@EmulationActivity,
+ R.drawable.ic_pip_play,
+ Intent(actionPlay),
+ pendingFlags
+ )
+ val playRemoteAction = RemoteAction(
+ playIcon,
+ getString(R.string.play),
+ getString(R.string.play),
+ playPendingIntent
+ )
+ pictureInPictureActions.add(playRemoteAction)
+ } else {
+ val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause)
+ val pausePendingIntent = PendingIntent.getBroadcast(
+ this@EmulationActivity,
+ R.drawable.ic_pip_pause,
+ Intent(actionPause),
+ pendingFlags
+ )
+ val pauseRemoteAction = RemoteAction(
+ pauseIcon,
+ getString(R.string.pause),
+ getString(R.string.pause),
+ pausePendingIntent
+ )
+ pictureInPictureActions.add(pauseRemoteAction)
+ }
+
+ if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
+ val unmuteIcon = Icon.createWithResource(
+ this@EmulationActivity,
+ R.drawable.ic_pip_unmute
+ )
+ val unmutePendingIntent = PendingIntent.getBroadcast(
+ this@EmulationActivity,
+ R.drawable.ic_pip_unmute,
+ Intent(actionUnmute),
+ pendingFlags
+ )
+ val unmuteRemoteAction = RemoteAction(
+ unmuteIcon,
+ getString(R.string.unmute),
+ getString(R.string.unmute),
+ unmutePendingIntent
+ )
+ pictureInPictureActions.add(unmuteRemoteAction)
+ } else {
+ val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute)
+ val mutePendingIntent = PendingIntent.getBroadcast(
+ this@EmulationActivity,
+ R.drawable.ic_pip_mute,
+ Intent(actionMute),
+ pendingFlags
+ )
+ val muteRemoteAction = RemoteAction(
+ muteIcon,
+ getString(R.string.mute),
+ getString(R.string.mute),
+ mutePendingIntent
+ )
+ pictureInPictureActions.add(muteRemoteAction)
+ }
+
+ return this.apply { setActions(pictureInPictureActions) }
+ }
+
+ fun buildPictureInPictureParams() {
+ val pictureInPictureParamsBuilder = PictureInPictureParams.Builder()
+ .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val isEmulationActive = emulationViewModel.emulationStarted.value &&
+ !emulationViewModel.isEmulationStopping.value
+ pictureInPictureParamsBuilder.setAutoEnterEnabled(
+ BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive
+ )
+ }
+ setPictureInPictureParams(pictureInPictureParamsBuilder.build())
+ }
+
+ private var pictureInPictureReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ if (intent.action == actionPlay) {
+ if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation()
+ } else if (intent.action == actionPause) {
+ if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation()
+ }
+ if (intent.action == actionUnmute) {
+ if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
+ BooleanSetting.AUDIO_MUTED.setBoolean(false)
+ }
+ } else if (intent.action == actionMute) {
+ if (!BooleanSetting.AUDIO_MUTED.getBoolean()) {
+ BooleanSetting.AUDIO_MUTED.setBoolean(true)
+ }
+ }
+ buildPictureInPictureParams()
+ }
+ }
+
+ @SuppressLint("UnspecifiedRegisterReceiverFlag")
+ override fun onPictureInPictureModeChanged(
+ isInPictureInPictureMode: Boolean,
+ newConfig: Configuration
+ ) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
+ if (isInPictureInPictureMode) {
+ IntentFilter().apply {
+ addAction(actionPause)
+ addAction(actionPlay)
+ addAction(actionMute)
+ addAction(actionUnmute)
+ }.also {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(pictureInPictureReceiver, it)
+ }
+ }
+ } else {
+ try {
+ unregisterReceiver(pictureInPictureReceiver)
+ } catch (ignored: Exception) {
+ }
+ // Always resume audio, since there is no UI button
+ if (BooleanSetting.AUDIO_MUTED.getBoolean()) {
+ BooleanSetting.AUDIO_MUTED.setBoolean(false)
+ }
+ }
+ }
+
+ fun onEmulationStarted() {
+ emulationViewModel.setEmulationStarted(true)
+ }
+
+ fun onEmulationStopped(status: Int) {
+ if (status == 0 && emulationViewModel.programChanged.value == -1) {
+ finish()
+ }
+ emulationViewModel.setEmulationStopped(true)
+ }
+
+ fun onProgramChanged(programIndex: Int) {
+ emulationViewModel.setProgramChanged(programIndex)
+ }
+
+ private fun startMotionSensorListener() {
+ val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME)
+ sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME)
+ }
+
+ private fun stopMotionSensorListener() {
+ val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+
+ sensorManager.unregisterListener(this, gyroSensor)
+ sensorManager.unregisterListener(this, accelSensor)
+ }
+
+ companion object {
+ const val EXTRA_SELECTED_GAME = "SelectedGame"
+
+ fun launch(activity: AppCompatActivity, game: Game) {
+ val launcher = Intent(activity, EmulationActivity::class.java)
+ launcher.putExtra(EXTRA_SELECTED_GAME, game)
+ activity.startActivity(launcher)
+ }
+
+ private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
+ if (view == null) {
+ return true
+ }
+ val viewBounds = Rect()
+ view.getGlobalVisibleRect(viewBounds)
+ return !viewBounds.contains(x.roundToInt(), y.roundToInt())
+ }
+ }
+}
diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt
new file mode 100644
index 0000000..b145080
--- /dev/null
+++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2024 sudachi Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.sudachi.sudachi_emu.adapters
+
+import android.annotation.SuppressLint
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
+import androidx.recyclerview.widget.RecyclerView
+
+/**
+ * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate
+ * code used in every [RecyclerView].
+ * Type assigned to [Model] must inherit from [Object] in order to be compared properly.
+ * @param exact Decides whether each item will be compared by reference or by their contents
+ */
+abstract class AbstractDiffAdapter>(
+ exact: Boolean = true
+) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) {
+ override fun onBindViewHolder(holder: Holder, position: Int) =
+ holder.bind(currentList[position])
+
+ private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
+ if (exact) {
+ return oldItem === newItem
+ }
+ return oldItem == newItem
+ }
+
+ @SuppressLint("DiffUtilEquals")
+ override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt
new file mode 100644
index 0000000..92824b2
--- /dev/null
+++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.sudachi.sudachi_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import org.sudachi.sudachi_emu.databinding.ListItemAddonBinding
+import org.sudachi.sudachi_emu.model.Patch
+import org.sudachi.sudachi_emu.model.AddonViewModel
+import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
+
+class AddonAdapter(val addonViewModel: AddonViewModel) :
+ AbstractDiffAdapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder {
+ ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ .also { return AddonViewHolder(it) }
+ }
+
+ inner class AddonViewHolder(val binding: ListItemAddonBinding) :
+ AbstractViewHolder(binding) {
+ override fun bind(model: Patch) {
+ binding.root.setOnClickListener {
+ binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked
+ }
+ binding.title.text = model.name
+ binding.version.text = model.version
+ binding.addonCheckbox.setOnCheckedChangeListener { _, checked ->
+ model.enabled = checked
+ }
+ binding.addonCheckbox.isChecked = model.enabled
+ binding.buttonDelete.setOnClickListener {
+ addonViewModel.setAddonToDelete(model)
+ }
+ }
+ }
+}
diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt
new file mode 100644
index 0000000..10db1f7
--- /dev/null
+++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2023 sudachi Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.sudachi.sudachi_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.content.res.ResourcesCompat
+import androidx.fragment.app.FragmentActivity
+import androidx.navigation.findNavController
+import org.sudachi.sudachi_emu.HomeNavigationDirections
+import org.sudachi.sudachi_emu.NativeLibrary
+import org.sudachi.sudachi_emu.R
+import org.sudachi.sudachi_emu.SudachiApplication
+import org.sudachi.sudachi_emu.databinding.CardSimpleOutlinedBinding
+import org.sudachi.sudachi_emu.model.Applet
+import org.sudachi.sudachi_emu.model.AppletInfo
+import org.sudachi.sudachi_emu.model.Game
+import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder
+
+class AppletAdapter(val activity: FragmentActivity, applets: List