diff --git a/src/android/.gitignore b/src/android/.gitignore
new file mode 100644
index 0000000000..40b6c5cd05
--- /dev/null
+++ b/src/android/.gitignore
@@ -0,0 +1,62 @@
+# Built application files
+*.apk
+*.ap_
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+
+# Keystore files
+# Uncomment the following line if you do not want to check your keystore files in.
+#*.jks
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# CXX compile cache
+app/.cxx
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle
new file mode 100644
index 0000000000..5a108743b6
--- /dev/null
+++ b/src/android/app/build.gradle
@@ -0,0 +1,163 @@
+apply plugin: 'com.android.application'
+
+/**
+ * Use the number of seconds/10 since Jan 1 2016 as the versionCode.
+ * This lets us upload a new build at most every 10 seconds for the
+ * next 680 years.
+ */
+def autoVersion = (int) (((new Date().getTime() / 1000) - 1451606400) / 10)
+def buildType
+def abiFilter = "arm64-v8a" //, "x86"
+
+android {
+ compileSdkVersion 32
+ ndkVersion "25.1.8937393"
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ lintOptions {
+ // This is important as it will run lint but not abort on error
+ // Lint has some overly obnoxious "errors" that should really be warnings
+ abortOnError false
+
+ //Uncomment disable lines for test builds...
+ //disable 'MissingTranslation'bin
+ //disable 'ExtraTranslation'
+ }
+
+ defaultConfig {
+ // TODO If this is ever modified, change application_id in strings.xml
+ applicationId "org.citra.citra_emu"
+ minSdkVersion 28
+ targetSdkVersion 29
+ versionCode autoVersion
+ versionName getVersion()
+ ndk.abiFilters abiFilter
+ }
+
+ signingConfigs {
+ //release {
+ // storeFile file('')
+ // storePassword System.getenv('ANDROID_KEYPASS')
+ // keyAlias = 'key0'
+ // keyPassword System.getenv('ANDROID_KEYPASS')
+ //}
+ }
+
+ applicationVariants.all { variant ->
+ buildType = variant.buildType.name // sets the current build type
+ }
+
+ // Define build types, which are orthogonal to product flavors.
+ buildTypes {
+
+ // Signed by release key, allowing for upload to Play Store.
+ release {
+ signingConfig signingConfigs.debug
+ }
+
+ // builds a release build that doesn't need signing
+ // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
+ relWithDebInfo {
+ initWith release
+ applicationIdSuffix ".debug"
+ versionNameSuffix '-debug'
+ signingConfig signingConfigs.debug
+ minifyEnabled false
+ testCoverageEnabled false
+ debuggable true
+ jniDebuggable true
+ }
+
+ // Signed by debug key disallowing distribution on Play Store.
+ // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
+ debug {
+ // TODO If this is ever modified, change application_id in debug/strings.xml
+ applicationIdSuffix ".debug"
+ versionNameSuffix '-debug'
+ debuggable true
+ jniDebuggable true
+ }
+ }
+
+ flavorDimensions "version"
+ productFlavors {
+ canary {
+ dimension "version"
+ applicationIdSuffix ".canary"
+ }
+ nightly {
+ dimension "version"
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ version "3.22.1"
+ path "../../../CMakeLists.txt"
+ }
+ }
+
+ defaultConfig {
+ externalNativeBuild {
+ cmake {
+ arguments "-DENABLE_QT=0", // Don't use QT
+ "-DENABLE_SDL2=0", // Don't use SDL
+ "-DENABLE_WEB_SERVICE=0", // Don't use telemetry
+ "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work
+ "-DYUZU_USE_BUNDLED_VCPKG=ON",
+ "-DYUZU_USE_BUNDLED_FFMPEG=ON"
+
+ abiFilters abiFilter
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.5.1'
+ implementation 'androidx.exifinterface:exifinterface:1.3.4'
+ implementation 'androidx.cardview:cardview:1.0.0'
+ implementation 'androidx.recyclerview:recyclerview:1.2.1'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1'
+ implementation 'androidx.fragment:fragment:1.5.3'
+ implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
+ implementation 'com.google.android.material:material:1.6.1'
+
+ // For loading huge screenshots from the disk.
+ implementation 'com.squareup.picasso:picasso:2.71828'
+
+ // Allows FRP-style asynchronous operations in Android.
+ implementation 'io.reactivex:rxandroid:1.2.1'
+ implementation 'com.nononsenseapps:filepicker:4.2.1'
+ implementation 'org.ini4j:ini4j:0.5.4'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
+ implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
+
+ // Please don't upgrade the billing library as the newer version is not GPL-compatible
+ implementation 'com.android.billingclient:billing:2.0.3'
+}
+
+def getVersion() {
+ def versionName = '0.0'
+
+ try {
+ versionName = 'git describe --always --long'.execute([], project.rootDir).text
+ .trim()
+ .replaceAll(/(-0)?-[^-]+$/, "")
+ } catch (Exception) {
+ logger.error('Cannot find git, defaulting to dummy version number')
+ }
+
+ if (System.getenv("GITHUB_ACTIONS") != null) {
+ def gitTag = System.getenv("GIT_TAG_NAME")
+ versionName = gitTag ?: versionName
+ }
+
+ return versionName
+}
diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/src/android/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java
new file mode 100644
index 0000000000..6a25f2ce6a
--- /dev/null
+++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java
@@ -0,0 +1,3 @@
+package org.citra.citra_emu;
+
+import android.content.Context;
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..c2463e079a
--- /dev/null
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
new file mode 100644
index 0000000000..41ac7e27c0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java
@@ -0,0 +1,56 @@
+// Copyright 2019 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu;
+
+import android.app.Application;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Build;
+
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.PermissionsHandler;
+
+public class CitraApplication extends Application {
+ public static GameDatabase databaseHelper;
+ private static CitraApplication application;
+
+ private void createNotificationChannel() {
+ // Create the NotificationChannel, but only on API 26+ because
+ // the NotificationChannel class is new and not in the support library
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ CharSequence name = getString(R.string.app_notification_channel_name);
+ String description = getString(R.string.app_notification_channel_description);
+ NotificationChannel channel = new NotificationChannel(getString(R.string.app_notification_channel_id), name, NotificationManager.IMPORTANCE_LOW);
+ channel.setDescription(description);
+ channel.setSound(null, null);
+ channel.setVibrationPattern(null);
+ // Register the channel with the system; you can't change the importance
+ // or other notification behaviors after this
+ NotificationManager notificationManager = getSystemService(NotificationManager.class);
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ application = this;
+
+ if (PermissionsHandler.hasWriteAccess(getApplicationContext())) {
+ DirectoryInitialization.start(getApplicationContext());
+ }
+
+ NativeLibrary.LogDeviceInfo();
+ createNotificationChannel();
+
+ databaseHelper = new GameDatabase(this);
+ }
+
+ public static Context getAppContext() {
+ return application.getApplicationContext();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
new file mode 100644
index 0000000000..baff99dc8b
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java
@@ -0,0 +1,631 @@
+/*
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.text.Html;
+import android.text.method.LinkMovementMethod;
+import android.view.Surface;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.DialogFragment;
+
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+import org.citra.citra_emu.utils.Log;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import static android.Manifest.permission.CAMERA;
+import static android.Manifest.permission.RECORD_AUDIO;
+
+/**
+ * Class which contains methods that interact
+ * with the native side of the Citra code.
+ */
+public final class NativeLibrary {
+ /**
+ * Default touchscreen device
+ */
+ public static final String TouchScreenDevice = "Touchscreen";
+ public static WeakReference sEmulationActivity = new WeakReference<>(null);
+
+ private static boolean alertResult = false;
+ private static String alertPromptResult = "";
+ private static int alertPromptButton = 0;
+ private static final Object alertPromptLock = new Object();
+ private static boolean alertPromptInProgress = false;
+ private static String alertPromptCaption = "";
+ private static int alertPromptButtonConfig = 0;
+ private static EditText alertPromptEditText = null;
+
+ static {
+ try {
+ System.loadLibrary("yuzu-android");
+ } catch (UnsatisfiedLinkError ex) {
+ Log.error("[NativeLibrary] " + ex.toString());
+ }
+ }
+
+ private NativeLibrary() {
+ // Disallows instantiation.
+ }
+
+ /**
+ * 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.
+ */
+ public static native boolean onGamePadEvent(String Device, int Button, int Action);
+
+ /**
+ * Handles gamepad 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
+ */
+ public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis);
+
+ /**
+ * Handles gamepad movement events.
+ *
+ * @param Device The device ID of the gamepad.
+ * @param Axis_id The axis ID
+ * @param axis_val The value of the axis represented by the given ID.
+ */
+ public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val);
+
+ /**
+ * Handles touch events.
+ *
+ * @param x_axis The value of the x-axis.
+ * @param y_axis The value of the y-axis
+ * @param pressed To identify if the touch held down or released.
+ * @return true if the pointer is within the touchscreen
+ */
+ public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed);
+
+ /**
+ * Handles touch movement.
+ *
+ * @param x_axis The value of the instantaneous x-axis.
+ * @param y_axis The value of the instantaneous y-axis.
+ */
+ public static native void onTouchMoved(float x_axis, float y_axis);
+
+ public static native void ReloadSettings();
+
+ public static native String GetUserSetting(String gameID, String Section, String Key);
+
+ public static native void SetUserSetting(String gameID, String Section, String Key, String Value);
+
+ public static native void InitGameIni(String gameID);
+
+ /**
+ * Gets the embedded icon within the given ROM.
+ *
+ * @param filename the file path to the ROM.
+ * @return an integer array containing the color data for the icon.
+ */
+ public static native int[] GetIcon(String filename);
+
+ /**
+ * 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.
+ */
+ public static native String GetTitle(String filename);
+
+ public static native String GetDescription(String filename);
+
+ public static native String GetGameId(String filename);
+
+ public static native String GetRegions(String filename);
+
+ public static native String GetCompany(String filename);
+
+ public static native String GetGitRevision();
+
+ /**
+ * Sets the current working user directory
+ * If not set, it auto-detects a location
+ */
+ public static native void SetUserDirectory(String directory);
+
+ // Create the config.ini file.
+ public static native void CreateConfigFile();
+
+ public static native int DefaultCPUCore();
+
+ /**
+ * Begins emulation.
+ */
+ public static native void Run(String path);
+
+ /**
+ * Begins emulation from the specified savestate.
+ */
+ public static native void Run(String path, String savestatePath, boolean deleteSavestate);
+
+ // Surface Handling
+ public static native void SurfaceChanged(Surface surf);
+
+ public static native void SurfaceDestroyed();
+
+ public static native void DoFrame();
+
+ /**
+ * Unpauses emulation from a paused state.
+ */
+ public static native void UnPauseEmulation();
+
+ /**
+ * Pauses emulation.
+ */
+ public static native void PauseEmulation();
+
+ /**
+ * Stops emulation.
+ */
+ public static native void StopEmulation();
+
+ /**
+ * Returns true if emulation is running (or is paused).
+ */
+ public static native boolean IsRunning();
+
+ /**
+ * Returns the performance stats for the current game
+ **/
+ public static native double[] GetPerfStats();
+
+ /**
+ * Notifies the core emulation that the orientation has changed.
+ */
+ public static native void NotifyOrientationChange(int layout_option, int rotation);
+
+ public enum CoreError {
+ ErrorSystemFiles,
+ ErrorSavestate,
+ ErrorUnknown,
+ }
+
+ private static boolean coreErrorAlertResult = false;
+ private static final Object coreErrorAlertLock = new Object();
+
+ public static class CoreErrorDialogFragment extends DialogFragment {
+ static CoreErrorDialogFragment newInstance(String title, String message) {
+ CoreErrorDialogFragment frag = new CoreErrorDialogFragment();
+ Bundle args = new Bundle();
+ args.putString("title", title);
+ args.putString("message", message);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Activity emulationActivity = Objects.requireNonNull(getActivity());
+
+ final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title"));
+ final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message"));
+
+ return new AlertDialog.Builder(emulationActivity)
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.continue_button, (dialog, which) -> {
+ coreErrorAlertResult = true;
+ synchronized (coreErrorAlertLock) {
+ coreErrorAlertLock.notify();
+ }
+ })
+ .setNegativeButton(R.string.abort_button, (dialog, which) -> {
+ coreErrorAlertResult = false;
+ synchronized (coreErrorAlertLock) {
+ coreErrorAlertLock.notify();
+ }
+ }).setOnDismissListener(dialog -> {
+ coreErrorAlertResult = true;
+ synchronized (coreErrorAlertLock) {
+ coreErrorAlertLock.notify();
+ }
+ }).create();
+ }
+ }
+
+ private static void OnCoreErrorImpl(String title, String message) {
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present");
+ return;
+ }
+
+ CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message);
+ fragment.show(emulationActivity.getSupportFragmentManager(), "coreError");
+ }
+
+ /**
+ * Handles a core error.
+ * @return true: continue; false: abort
+ */
+ public static boolean OnCoreError(CoreError error, String details) {
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present");
+ return false;
+ }
+
+ String title, message;
+ switch (error) {
+ case ErrorSystemFiles: {
+ title = emulationActivity.getString(R.string.system_archive_not_found);
+ message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details);
+ break;
+ }
+ case ErrorSavestate: {
+ title = emulationActivity.getString(R.string.save_load_error);
+ message = details;
+ break;
+ }
+ case ErrorUnknown: {
+ title = emulationActivity.getString(R.string.fatal_error);
+ message = emulationActivity.getString(R.string.fatal_error_message);
+ break;
+ }
+ default: {
+ return true;
+ }
+ }
+
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message));
+
+ // Wait for the lock to notify that it is complete.
+ synchronized (coreErrorAlertLock) {
+ try {
+ coreErrorAlertLock.wait();
+ } catch (Exception ignored) {
+ }
+ }
+
+ return coreErrorAlertResult;
+ }
+
+ public static boolean isPortraitMode() {
+ return CitraApplication.getAppContext().getResources().getConfiguration().orientation ==
+ Configuration.ORIENTATION_PORTRAIT;
+ }
+
+ public static int landscapeScreenLayout() {
+ return EmulationMenuSettings.getLandscapeScreenLayout();
+ }
+
+ public static boolean displayAlertMsg(final String caption, final String text,
+ final boolean yesNo) {
+ Log.error("[NativeLibrary] Alert: " + text);
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ boolean result = false;
+ if (emulationActivity == null) {
+ Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert.");
+ } else {
+ // Create object used for waiting.
+ final Object lock = new Object();
+ AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity)
+ .setTitle(caption)
+ .setMessage(text);
+
+ // If not yes/no dialog just have one button that dismisses modal,
+ // otherwise have a yes and no button that sets alertResult accordingly.
+ if (!yesNo) {
+ builder
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) ->
+ {
+ dialog.dismiss();
+ synchronized (lock) {
+ lock.notify();
+ }
+ });
+ } else {
+ alertResult = false;
+
+ builder
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) ->
+ {
+ alertResult = true;
+ dialog.dismiss();
+ synchronized (lock) {
+ lock.notify();
+ }
+ })
+ .setNegativeButton(android.R.string.no, (dialog, whichButton) ->
+ {
+ alertResult = false;
+ dialog.dismiss();
+ synchronized (lock) {
+ lock.notify();
+ }
+ });
+ }
+
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread(builder::show);
+
+ // Wait for the lock to notify that it is complete.
+ synchronized (lock) {
+ try {
+ lock.wait();
+ } catch (Exception e) {
+ }
+ }
+
+ if (yesNo)
+ result = alertResult;
+ }
+ return result;
+ }
+
+ public static void retryDisplayAlertPrompt() {
+ if (!alertPromptInProgress) {
+ return;
+ }
+ displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show();
+ }
+
+ public static String displayAlertPrompt(String caption, String text, int buttonConfig) {
+ alertPromptCaption = caption;
+ alertPromptButtonConfig = buttonConfig;
+ alertPromptInProgress = true;
+
+ // Show the AlertDialog on the main thread
+ sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show());
+
+ // Wait for the lock to notify that it is complete
+ synchronized (alertPromptLock) {
+ try {
+ alertPromptLock.wait();
+ } catch (Exception e) {
+ }
+ }
+ alertPromptInProgress = false;
+
+ return alertPromptResult;
+ }
+
+ public static AlertDialog.Builder displayAlertPromptImpl(String caption, String text, int buttonConfig) {
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ alertPromptResult = "";
+ alertPromptButton = 0;
+
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin);
+
+ // Set up the input
+ alertPromptEditText = new EditText(CitraApplication.getAppContext());
+ alertPromptEditText.setText(text);
+ alertPromptEditText.setSingleLine();
+ alertPromptEditText.setLayoutParams(params);
+
+ FrameLayout container = new FrameLayout(emulationActivity);
+ container.addView(alertPromptEditText);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity)
+ .setTitle(caption)
+ .setView(container)
+ .setPositiveButton(android.R.string.ok, (dialogInterface, i) ->
+ {
+ alertPromptButton = buttonConfig;
+ alertPromptResult = alertPromptEditText.getText().toString();
+ synchronized (alertPromptLock) {
+ alertPromptLock.notifyAll();
+ }
+ })
+ .setOnDismissListener(dialogInterface ->
+ {
+ alertPromptResult = "";
+ synchronized (alertPromptLock) {
+ alertPromptLock.notifyAll();
+ }
+ });
+
+ if (buttonConfig > 0) {
+ builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) ->
+ {
+ alertPromptResult = "";
+ synchronized (alertPromptLock) {
+ alertPromptLock.notifyAll();
+ }
+ });
+ }
+
+ return builder;
+ }
+
+ public static int alertPromptButton() {
+ return alertPromptButton;
+ }
+
+ public static void exitEmulationActivity(int resultCode) {
+ final int Success = 0;
+ final int ErrorNotInitialized = 1;
+ final int ErrorGetLoader = 2;
+ final int ErrorSystemMode = 3;
+ final int ErrorLoader = 4;
+ final int ErrorLoader_ErrorEncrypted = 5;
+ final int ErrorLoader_ErrorInvalidFormat = 6;
+ final int ErrorSystemFiles = 7;
+ final int ErrorVideoCore = 8;
+ final int ErrorVideoCore_ErrorGenericDrivers = 9;
+ final int ErrorVideoCore_ErrorBelowGL33 = 10;
+ final int ShutdownRequested = 11;
+ final int ErrorUnknown = 12;
+
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.");
+ return;
+ }
+
+ int captionId = R.string.loader_error_invalid_format;
+ if (resultCode == ErrorLoader_ErrorEncrypted) {
+ captionId = R.string.loader_error_encrypted;
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity)
+ .setTitle(captionId)
+ .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY))
+ .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish())
+ .setOnDismissListener(dialogInterface -> emulationActivity.finish());
+ emulationActivity.runOnUiThread(() -> {
+ AlertDialog alert = builder.create();
+ alert.show();
+ ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance());
+ });
+ }
+
+ public static void setEmulationActivity(EmulationActivity emulationActivity) {
+ Log.verbose("[NativeLibrary] Registering EmulationActivity.");
+ sEmulationActivity = new WeakReference<>(emulationActivity);
+ }
+
+ public static void clearEmulationActivity() {
+ Log.verbose("[NativeLibrary] Unregistering EmulationActivity.");
+
+ sEmulationActivity.clear();
+ }
+
+ private static final Object cameraPermissionLock = new Object();
+ private static boolean cameraPermissionGranted = false;
+ public static final int REQUEST_CODE_NATIVE_CAMERA = 800;
+
+ public static boolean RequestCameraPermission() {
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present");
+ return false;
+ }
+ if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ // Permission already granted
+ return true;
+ }
+ emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA);
+
+ // Wait until result is returned
+ synchronized (cameraPermissionLock) {
+ try {
+ cameraPermissionLock.wait();
+ } catch (InterruptedException ignored) {
+ }
+ }
+ return cameraPermissionGranted;
+ }
+
+ public static void CameraPermissionResult(boolean granted) {
+ cameraPermissionGranted = granted;
+ synchronized (cameraPermissionLock) {
+ cameraPermissionLock.notify();
+ }
+ }
+
+ private static final Object micPermissionLock = new Object();
+ private static boolean micPermissionGranted = false;
+ public static final int REQUEST_CODE_NATIVE_MIC = 900;
+
+ public static boolean RequestMicPermission() {
+ final EmulationActivity emulationActivity = sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.error("[NativeLibrary] EmulationActivity not present");
+ return false;
+ }
+ if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
+ // Permission already granted
+ return true;
+ }
+ emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC);
+
+ // Wait until result is returned
+ synchronized (micPermissionLock) {
+ try {
+ micPermissionLock.wait();
+ } catch (InterruptedException ignored) {
+ }
+ }
+ return micPermissionGranted;
+ }
+
+ public static void MicPermissionResult(boolean granted) {
+ micPermissionGranted = granted;
+ synchronized (micPermissionLock) {
+ micPermissionLock.notify();
+ }
+ }
+
+ /**
+ * Logs the Citra version, Android version and, CPU.
+ */
+ public static native void LogDeviceInfo();
+
+ /**
+ * Button type for use in onTouchEvent
+ */
+ public static final class ButtonType {
+ public static final int BUTTON_A = 0;
+ public static final int BUTTON_B = 1;
+ public static final int BUTTON_X = 2;
+ public static final int BUTTON_Y = 3;
+ public static final int BUTTON_START = 11;
+ public static final int BUTTON_SELECT = 12;
+ public static final int BUTTON_HOME = 19;
+ public static final int BUTTON_ZL = 9;
+ public static final int BUTTON_ZR = 10;
+ public static final int DPAD_UP = 14;
+ public static final int DPAD_DOWN = 16;
+ public static final int DPAD_LEFT = 13;
+ public static final int DPAD_RIGHT = 15;
+ public static final int STICK_LEFT = 5;
+ public static final int STICK_LEFT_UP = 714;
+ public static final int STICK_LEFT_DOWN = 715;
+ public static final int STICK_LEFT_LEFT = 716;
+ public static final int STICK_LEFT_RIGHT = 717;
+ public static final int STICK_C = 6;
+ public static final int STICK_C_UP = 719;
+ public static final int STICK_C_DOWN = 720;
+ public static final int STICK_C_LEFT = 771;
+ public static final int STICK_C_RIGHT = 772;
+ public static final int TRIGGER_L = 7;
+ public static final int TRIGGER_R = 8;
+ public static final int DPAD = 780;
+ public static final int BUTTON_DEBUG = 781;
+ public static final int BUTTON_GPIO14 = 782;
+ }
+
+ /**
+ * Button states
+ */
+ public static final class ButtonState {
+ public static final int RELEASED = 0;
+ public static final int PRESSED = 1;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java
new file mode 100644
index 0000000000..3083286e21
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java
@@ -0,0 +1,38 @@
+package org.citra.citra_emu.activities;
+
+import android.content.Intent;
+import android.os.Environment;
+
+import androidx.annotation.Nullable;
+
+import com.nononsenseapps.filepicker.AbstractFilePickerFragment;
+import com.nononsenseapps.filepicker.FilePickerActivity;
+
+import org.citra.citra_emu.fragments.CustomFilePickerFragment;
+
+import java.io.File;
+
+public class CustomFilePickerActivity extends FilePickerActivity {
+ public static final String EXTRA_TITLE = "filepicker.intent.TITLE";
+ public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS";
+
+ @Override
+ protected AbstractFilePickerFragment getFragment(
+ @Nullable final String startPath, final int mode, final boolean allowMultiple,
+ final boolean allowCreateDir, final boolean allowExistingFile,
+ final boolean singleClick) {
+ CustomFilePickerFragment fragment = new CustomFilePickerFragment();
+ // startPath is allowed to be null. In that case, default folder should be SD-card and not "/"
+ fragment.setArgs(
+ startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(),
+ mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick);
+
+ Intent intent = getIntent();
+ int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0);
+ fragment.setTitle(title);
+ String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS);
+ fragment.setAllowedExtensions(allowedExtensions);
+
+ return fragment;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
new file mode 100644
index 0000000000..47ef0fd23f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
@@ -0,0 +1,755 @@
+package org.citra.citra_emu.activities;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.util.SparseIntArray;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.fragment.app.FragmentActivity;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.ui.CheatsActivity;
+import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
+import org.citra.citra_emu.features.settings.ui.SettingsActivity;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.camera.StillImageCameraHelper;
+import org.citra.citra_emu.fragments.EmulationFragment;
+import org.citra.citra_emu.ui.main.MainActivity;
+import org.citra.citra_emu.utils.ControllerMappingHelper;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+import org.citra.citra_emu.utils.FileBrowserHelper;
+import org.citra.citra_emu.utils.FileUtil;
+import org.citra.citra_emu.utils.ForegroundService;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Retention;
+import java.util.Collections;
+import java.util.List;
+
+import static android.Manifest.permission.CAMERA;
+import static android.Manifest.permission.RECORD_AUDIO;
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+public final class EmulationActivity extends AppCompatActivity {
+ public static final String EXTRA_SELECTED_GAME = "SelectedGame";
+ public static final String EXTRA_SELECTED_TITLE = "SelectedTitle";
+ public static final int MENU_ACTION_EDIT_CONTROLS_PLACEMENT = 0;
+ public static final int MENU_ACTION_TOGGLE_CONTROLS = 1;
+ public static final int MENU_ACTION_ADJUST_SCALE = 2;
+ public static final int MENU_ACTION_EXIT = 3;
+ public static final int MENU_ACTION_SHOW_FPS = 4;
+ public static final int MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE = 5;
+ public static final int MENU_ACTION_SCREEN_LAYOUT_PORTRAIT = 6;
+ public static final int MENU_ACTION_SCREEN_LAYOUT_SINGLE = 7;
+ public static final int MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE = 8;
+ public static final int MENU_ACTION_SWAP_SCREENS = 9;
+ public static final int MENU_ACTION_RESET_OVERLAY = 10;
+ public static final int MENU_ACTION_SHOW_OVERLAY = 11;
+ public static final int MENU_ACTION_OPEN_SETTINGS = 12;
+ public static final int MENU_ACTION_LOAD_AMIIBO = 13;
+ public static final int MENU_ACTION_REMOVE_AMIIBO = 14;
+ public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15;
+ public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16;
+ public static final int MENU_ACTION_OPEN_CHEATS = 17;
+
+ public static final int REQUEST_SELECT_AMIIBO = 2;
+ private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000;
+ private static SparseIntArray buttonsActionsMap = new SparseIntArray();
+
+ static {
+ buttonsActionsMap.append(R.id.menu_emulation_edit_layout,
+ EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT);
+ buttonsActionsMap.append(R.id.menu_emulation_toggle_controls,
+ EmulationActivity.MENU_ACTION_TOGGLE_CONTROLS);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_adjust_scale, EmulationActivity.MENU_ACTION_ADJUST_SCALE);
+ buttonsActionsMap.append(R.id.menu_emulation_show_fps,
+ EmulationActivity.MENU_ACTION_SHOW_FPS);
+ buttonsActionsMap.append(R.id.menu_screen_layout_landscape,
+ EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE);
+ buttonsActionsMap.append(R.id.menu_screen_layout_portrait,
+ EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_PORTRAIT);
+ buttonsActionsMap.append(R.id.menu_screen_layout_single,
+ EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SINGLE);
+ buttonsActionsMap.append(R.id.menu_screen_layout_sidebyside,
+ EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE);
+ buttonsActionsMap.append(R.id.menu_emulation_swap_screens,
+ EmulationActivity.MENU_ACTION_SWAP_SCREENS);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_reset_overlay, EmulationActivity.MENU_ACTION_RESET_OVERLAY);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_show_overlay, EmulationActivity.MENU_ACTION_SHOW_OVERLAY);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_open_settings, EmulationActivity.MENU_ACTION_OPEN_SETTINGS);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_amiibo_load, EmulationActivity.MENU_ACTION_LOAD_AMIIBO);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_amiibo_remove, EmulationActivity.MENU_ACTION_REMOVE_AMIIBO);
+ buttonsActionsMap.append(R.id.menu_emulation_joystick_rel_center,
+ EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER);
+ buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable,
+ EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE);
+ buttonsActionsMap
+ .append(R.id.menu_emulation_open_cheats, EmulationActivity.MENU_ACTION_OPEN_CHEATS);
+ }
+
+ private View mDecorView;
+ private EmulationFragment mEmulationFragment;
+ private SharedPreferences mPreferences;
+ private ControllerMappingHelper mControllerMappingHelper;
+ private Intent foregroundService;
+ private boolean activityRecreated;
+ private String mSelectedTitle;
+ private String mPath;
+
+ public static void launch(FragmentActivity activity, String path, String title) {
+ Intent launcher = new Intent(activity, EmulationActivity.class);
+
+ launcher.putExtra(EXTRA_SELECTED_GAME, path);
+ launcher.putExtra(EXTRA_SELECTED_TITLE, title);
+ activity.startActivity(launcher);
+ }
+
+ public static void tryDismissRunningNotification(Activity activity) {
+ NotificationManagerCompat.from(activity).cancel(EMULATION_RUNNING_NOTIFICATION);
+ }
+
+ @Override
+ protected void onDestroy() {
+ stopService(foregroundService);
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ if (savedInstanceState == null) {
+ // Get params we were passed
+ Intent gameToEmulate = getIntent();
+ mPath = gameToEmulate.getStringExtra(EXTRA_SELECTED_GAME);
+ mSelectedTitle = gameToEmulate.getStringExtra(EXTRA_SELECTED_TITLE);
+ activityRecreated = false;
+ } else {
+ activityRecreated = true;
+ restoreState(savedInstanceState);
+ }
+
+ mControllerMappingHelper = new ControllerMappingHelper();
+
+ // Get a handle to the Window containing the UI.
+ mDecorView = getWindow().getDecorView();
+ mDecorView.setOnSystemUiVisibilityChangeListener(visibility ->
+ {
+ if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
+ // Go back to immersive fullscreen mode in 3s
+ Handler handler = new Handler(getMainLooper());
+ handler.postDelayed(this::enableFullscreenImmersive, 3000 /* 3s */);
+ }
+ });
+ // Set these options now so that the SurfaceView the game renders into is the right size.
+ enableFullscreenImmersive();
+
+ setTheme(R.style.CitraEmulationBase);
+
+ setContentView(R.layout.activity_emulation);
+
+ // Find or create the EmulationFragment
+ mEmulationFragment = (EmulationFragment) getSupportFragmentManager()
+ .findFragmentById(R.id.frame_emulation_fragment);
+ if (mEmulationFragment == null) {
+ mEmulationFragment = EmulationFragment.newInstance(mPath);
+ getSupportFragmentManager().beginTransaction()
+ .add(R.id.frame_emulation_fragment, mEmulationFragment)
+ .commit();
+ }
+
+ setTitle(mSelectedTitle);
+
+ mPreferences = PreferenceManager.getDefaultSharedPreferences(this);
+
+ // Start a foreground service to prevent the app from getting killed in the background
+ foregroundService = new Intent(EmulationActivity.this, ForegroundService.class);
+ startForegroundService(foregroundService);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ outState.putString(EXTRA_SELECTED_GAME, mPath);
+ outState.putString(EXTRA_SELECTED_TITLE, mSelectedTitle);
+ super.onSaveInstanceState(outState);
+ }
+
+ protected void restoreState(Bundle savedInstanceState) {
+ mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME);
+ mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE);
+
+ // If an alert prompt was in progress when state was restored, retry displaying it
+ NativeLibrary.retryDisplayAlertPrompt();
+ }
+
+ @Override
+ public void onRestart() {
+ super.onRestart();
+ }
+
+ @Override
+ public void onBackPressed() {
+ NativeLibrary.PauseEmulation();
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.emulation_close_game)
+ .setMessage(R.string.emulation_close_game_message)
+ .setPositiveButton(android.R.string.yes, (dialogInterface, i) ->
+ {
+ mEmulationFragment.stopEmulation();
+ finish();
+ })
+ .setNegativeButton(android.R.string.cancel, (dialogInterface, i) ->
+ NativeLibrary.UnPauseEmulation())
+ .setOnCancelListener(dialogInterface ->
+ NativeLibrary.UnPauseEmulation())
+ .create()
+ .show();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case NativeLibrary.REQUEST_CODE_NATIVE_CAMERA:
+ if (grantResults[0] != PackageManager.PERMISSION_GRANTED &&
+ shouldShowRequestPermissionRationale(CAMERA)) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.camera)
+ .setMessage(R.string.camera_permission_needed)
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+ }
+ NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ break;
+ case NativeLibrary.REQUEST_CODE_NATIVE_MIC:
+ if (grantResults[0] != PackageManager.PERMISSION_GRANTED &&
+ shouldShowRequestPermissionRationale(RECORD_AUDIO)) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.microphone)
+ .setMessage(R.string.microphone_permission_needed)
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+ }
+ NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ break;
+ default:
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ break;
+ }
+ }
+
+ private void enableFullscreenImmersive() {
+ // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar.
+ mDecorView.setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_IMMERSIVE);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ // Inflate the menu; this adds items to the action bar if it is present.
+ getMenuInflater().inflate(R.menu.menu_emulation, menu);
+
+ int layoutOptionMenuItem = R.id.menu_screen_layout_landscape;
+ switch (EmulationMenuSettings.getLandscapeScreenLayout()) {
+ case EmulationMenuSettings.LayoutOption_SingleScreen:
+ layoutOptionMenuItem = R.id.menu_screen_layout_single;
+ break;
+ case EmulationMenuSettings.LayoutOption_SideScreen:
+ layoutOptionMenuItem = R.id.menu_screen_layout_sidebyside;
+ break;
+ case EmulationMenuSettings.LayoutOption_MobilePortrait:
+ layoutOptionMenuItem = R.id.menu_screen_layout_portrait;
+ break;
+ }
+
+ menu.findItem(layoutOptionMenuItem).setChecked(true);
+ menu.findItem(R.id.menu_emulation_joystick_rel_center).setChecked(EmulationMenuSettings.getJoystickRelCenter());
+ menu.findItem(R.id.menu_emulation_dpad_slide_enable).setChecked(EmulationMenuSettings.getDpadSlideEnable());
+ menu.findItem(R.id.menu_emulation_show_fps).setChecked(EmulationMenuSettings.getShowFps());
+ menu.findItem(R.id.menu_emulation_swap_screens).setChecked(EmulationMenuSettings.getSwapScreens());
+ menu.findItem(R.id.menu_emulation_show_overlay).setChecked(EmulationMenuSettings.getShowOverlay());
+
+ return true;
+ }
+
+ private void DisplaySavestateWarning() {
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ if (preferences.getBoolean("savestateWarningShown", false)) {
+ return;
+ }
+
+ LayoutInflater inflater = mEmulationFragment.requireActivity().getLayoutInflater();
+ View view = inflater.inflate(R.layout.dialog_checkbox, null);
+ CheckBox checkBox = view.findViewById(R.id.checkBox);
+
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.savestate_warning_title)
+ .setMessage(R.string.savestate_warning_message)
+ .setView(view)
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ preferences.edit().putBoolean("savestateWarningShown", checkBox.isChecked()).apply();
+ })
+ .show();
+ }
+
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.menu_emulation_save_state).setVisible(false);
+ menu.findItem(R.id.menu_emulation_load_state).setVisible(false);
+ return true;
+ }
+
+ @SuppressWarnings("WrongConstant")
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int action = buttonsActionsMap.get(item.getItemId(), -1);
+
+ switch (action) {
+ // Edit the placement of the controls
+ case MENU_ACTION_EDIT_CONTROLS_PLACEMENT:
+ editControlsPlacement();
+ break;
+
+ // Enable/Disable specific buttons or the entire input overlay.
+ case MENU_ACTION_TOGGLE_CONTROLS:
+ toggleControls();
+ break;
+
+ // Adjust the scale of the overlay controls.
+ case MENU_ACTION_ADJUST_SCALE:
+ adjustScale();
+ break;
+
+ // Toggle the visibility of the Performance stats TextView
+ case MENU_ACTION_SHOW_FPS: {
+ final boolean isEnabled = !EmulationMenuSettings.getShowFps();
+ EmulationMenuSettings.setShowFps(isEnabled);
+ item.setChecked(isEnabled);
+
+ mEmulationFragment.updateShowFpsOverlay();
+ break;
+ }
+ // Sets the screen layout to Landscape
+ case MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE:
+ changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, item);
+ break;
+
+ // Sets the screen layout to Portrait
+ case MENU_ACTION_SCREEN_LAYOUT_PORTRAIT:
+ changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, item);
+ break;
+
+ // Sets the screen layout to Single
+ case MENU_ACTION_SCREEN_LAYOUT_SINGLE:
+ changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, item);
+ break;
+
+ // Sets the screen layout to Side by Side
+ case MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE:
+ changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, item);
+ break;
+
+ // Swap the top and bottom screen locations
+ case MENU_ACTION_SWAP_SCREENS: {
+ final boolean isEnabled = !EmulationMenuSettings.getSwapScreens();
+ EmulationMenuSettings.setSwapScreens(isEnabled);
+ item.setChecked(isEnabled);
+ break;
+ }
+
+ // Reset overlay placement
+ case MENU_ACTION_RESET_OVERLAY:
+ resetOverlay();
+ break;
+
+ // Show or hide overlay
+ case MENU_ACTION_SHOW_OVERLAY: {
+ final boolean isEnabled = !EmulationMenuSettings.getShowOverlay();
+ EmulationMenuSettings.setShowOverlay(isEnabled);
+ item.setChecked(isEnabled);
+
+ mEmulationFragment.refreshInputOverlay();
+ break;
+ }
+
+ case MENU_ACTION_EXIT:
+ mEmulationFragment.stopEmulation();
+ finish();
+ break;
+
+ case MENU_ACTION_OPEN_SETTINGS:
+ SettingsActivity.launch(this, SettingsFile.FILE_NAME_CONFIG, "");
+ break;
+
+ case MENU_ACTION_LOAD_AMIIBO:
+ FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO,
+ R.string.select_amiibo,
+ Collections.singletonList("bin"), false);
+ break;
+
+ case MENU_ACTION_REMOVE_AMIIBO:
+ RemoveAmiibo();
+ break;
+
+ case MENU_ACTION_JOYSTICK_REL_CENTER:
+ final boolean isJoystickRelCenterEnabled = !EmulationMenuSettings.getJoystickRelCenter();
+ EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled);
+ item.setChecked(isJoystickRelCenterEnabled);
+ break;
+
+ case MENU_ACTION_DPAD_SLIDE_ENABLE:
+ final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable();
+ EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled);
+ item.setChecked(isDpadSlideEnabled);
+ break;
+
+ case MENU_ACTION_OPEN_CHEATS:
+ CheatsActivity.launch(this);
+ break;
+ }
+
+ return true;
+ }
+
+ private void changeScreenOrientation(int layoutOption, MenuItem item) {
+ item.setChecked(true);
+ NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay()
+ .getRotation());
+ EmulationMenuSettings.setLandscapeScreenLayout(layoutOption);
+ }
+
+ private void editControlsPlacement() {
+ if (mEmulationFragment.isConfiguringControls()) {
+ mEmulationFragment.stopConfiguringControls();
+ } else {
+ mEmulationFragment.startConfiguringControls();
+ }
+ }
+
+ // Gets button presses
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ int action;
+ int button = mPreferences.getInt(InputBindingSetting.getInputButtonKey(event.getKeyCode()), event.getKeyCode());
+
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ // Handling the case where the back button is pressed.
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ onBackPressed();
+ return true;
+ }
+
+ // Normal key events.
+ action = NativeLibrary.ButtonState.PRESSED;
+ break;
+ case KeyEvent.ACTION_UP:
+ action = NativeLibrary.ButtonState.RELEASED;
+ break;
+ default:
+ return false;
+ }
+ InputDevice input = event.getDevice();
+
+ if (input == null) {
+ // Controller was disconnected
+ return false;
+ }
+
+ return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent result) {
+ super.onActivityResult(requestCode, resultCode, result);
+ switch (requestCode) {
+ case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER:
+ StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null);
+ break;
+ case REQUEST_SELECT_AMIIBO:
+ // If the user picked a file, as opposed to just backing out.
+ if (resultCode == MainActivity.RESULT_OK) {
+ String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result);
+ if (selectedFiles == null)
+ return;
+
+ onAmiiboSelected(selectedFiles[0]);
+ }
+ break;
+ }
+ }
+
+ private void onAmiiboSelected(String selectedFile) {
+ File file = new File(selectedFile);
+ boolean success = false;
+ try {
+ byte[] bytes = FileUtil.getBytesFromFile(file);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ if (!success) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.amiibo_load_error)
+ .setMessage(R.string.amiibo_load_error_message)
+ .setPositiveButton(android.R.string.ok, null)
+ .create()
+ .show();
+ }
+ }
+
+ private void RemoveAmiibo() {
+
+ }
+
+ private void toggleControls() {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ boolean[] enabledButtons = new boolean[14];
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.emulation_toggle_controls);
+
+ for (int i = 0; i < enabledButtons.length; i++) {
+ // Buttons that are disabled by default
+ boolean defaultValue = true;
+ switch (i) {
+ case 6: // ZL
+ case 7: // ZR
+ case 12: // C-stick
+ defaultValue = false;
+ break;
+ }
+
+ enabledButtons[i] = mPreferences.getBoolean("buttonToggle" + i, defaultValue);
+ }
+ builder.setMultiChoiceItems(R.array.n3dsButtons, enabledButtons,
+ (dialog, indexSelected, isChecked) -> editor
+ .putBoolean("buttonToggle" + indexSelected, isChecked));
+ builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) ->
+ {
+ editor.apply();
+
+ mEmulationFragment.refreshInputOverlay();
+ });
+
+ AlertDialog alertDialog = builder.create();
+ alertDialog.show();
+ }
+
+ private void adjustScale() {
+ LayoutInflater inflater = LayoutInflater.from(this);
+ View view = inflater.inflate(R.layout.dialog_seekbar, null);
+
+ final SeekBar seekbar = view.findViewById(R.id.seekbar);
+ final TextView value = view.findViewById(R.id.text_value);
+ final TextView units = view.findViewById(R.id.text_units);
+
+ seekbar.setMax(150);
+ seekbar.setProgress(mPreferences.getInt("controlScale", 50));
+ seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ value.setText(String.valueOf(progress + 50));
+ }
+
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ setControlScale(seekbar.getProgress());
+ }
+ });
+
+ value.setText(String.valueOf(seekbar.getProgress() + 50));
+ units.setText("%");
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.emulation_control_scale);
+ builder.setView(view);
+ final int previousProgress = seekbar.getProgress();
+ builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> {
+ setControlScale(previousProgress);
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) ->
+ {
+ setControlScale(seekbar.getProgress());
+ });
+ builder.setNeutralButton(R.string.slider_default, (dialogInterface, i) -> {
+ setControlScale(50);
+ });
+
+ AlertDialog alertDialog = builder.create();
+ alertDialog.show();
+ }
+
+ private void setControlScale(int scale) {
+ SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putInt("controlScale", scale);
+ editor.apply();
+ mEmulationFragment.refreshInputOverlay();
+ }
+
+ private void resetOverlay() {
+ new AlertDialog.Builder(this)
+ .setTitle(getString(R.string.emulation_touch_overlay_reset))
+ .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> mEmulationFragment.resetInputOverlay())
+ .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> {
+ })
+ .create()
+ .show();
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(MotionEvent event) {
+ if (((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)) {
+ return super.dispatchGenericMotionEvent(event);
+ }
+
+ // Don't attempt to do anything if we are disconnecting a device.
+ if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
+ return true;
+ }
+
+ InputDevice input = event.getDevice();
+ List motions = input.getMotionRanges();
+
+ float[] axisValuesCirclePad = {0.0f, 0.0f};
+ float[] axisValuesCStick = {0.0f, 0.0f};
+ float[] axisValuesDPad = {0.0f, 0.0f};
+ boolean isTriggerPressedLMapped = false;
+ boolean isTriggerPressedRMapped = false;
+ boolean isTriggerPressedZLMapped = false;
+ boolean isTriggerPressedZRMapped = false;
+ boolean isTriggerPressedL = false;
+ boolean isTriggerPressedR = false;
+ boolean isTriggerPressedZL = false;
+ boolean isTriggerPressedZR = false;
+
+ for (InputDevice.MotionRange range : motions) {
+ int axis = range.getAxis();
+ float origValue = event.getAxisValue(axis);
+ float value = mControllerMappingHelper.scaleAxis(input, axis, origValue);
+ int nextMapping = mPreferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1);
+ int guestOrientation = mPreferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1);
+
+ if (nextMapping == -1 || guestOrientation == -1) {
+ // Axis is unmapped
+ continue;
+ }
+
+ if ((value > 0.f && value < 0.1f) || (value < 0.f && value > -0.1f)) {
+ // Skip joystick wobble
+ value = 0.f;
+ }
+
+ if (nextMapping == NativeLibrary.ButtonType.STICK_LEFT) {
+ axisValuesCirclePad[guestOrientation] = value;
+ } else if (nextMapping == NativeLibrary.ButtonType.STICK_C) {
+ axisValuesCStick[guestOrientation] = value;
+ } else if (nextMapping == NativeLibrary.ButtonType.DPAD) {
+ axisValuesDPad[guestOrientation] = value;
+ } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_L) {
+ isTriggerPressedLMapped = true;
+ isTriggerPressedL = value != 0.f;
+ } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_R) {
+ isTriggerPressedRMapped = true;
+ isTriggerPressedR = value != 0.f;
+ } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZL) {
+ isTriggerPressedZLMapped = true;
+ isTriggerPressedZL = value != 0.f;
+ } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZR) {
+ isTriggerPressedZRMapped = true;
+ isTriggerPressedZR = value != 0.f;
+ }
+ }
+
+ // Circle-Pad and C-Stick status
+ NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]);
+ NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]);
+
+ // Triggers L/R and ZL/ZR
+ if (isTriggerPressedLMapped) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ }
+ if (isTriggerPressedRMapped) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ }
+ if (isTriggerPressedZLMapped) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ }
+ if (isTriggerPressedZRMapped) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED);
+ }
+
+ // Work-around to allow D-pad axis to be bound to emulated buttons
+ if (axisValuesDPad[0] == 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
+ }
+ if (axisValuesDPad[0] < 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED);
+ }
+ if (axisValuesDPad[0] > 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED);
+ }
+ if (axisValuesDPad[1] == 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
+ }
+ if (axisValuesDPad[1] < 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED);
+ }
+ if (axisValuesDPad[1] > 0.f) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED);
+ }
+
+ return true;
+ }
+
+ public boolean isActivityRecreated() {
+ return activityRecreated;
+ }
+
+ @Retention(SOURCE)
+ @IntDef({MENU_ACTION_EDIT_CONTROLS_PLACEMENT, MENU_ACTION_TOGGLE_CONTROLS, MENU_ACTION_ADJUST_SCALE,
+ MENU_ACTION_EXIT, MENU_ACTION_SHOW_FPS, MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE,
+ MENU_ACTION_SCREEN_LAYOUT_PORTRAIT, MENU_ACTION_SCREEN_LAYOUT_SINGLE, MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE,
+ MENU_ACTION_SWAP_SCREENS, MENU_ACTION_RESET_OVERLAY, MENU_ACTION_SHOW_OVERLAY, MENU_ACTION_OPEN_SETTINGS})
+ public @interface MenuAction {
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
new file mode 100644
index 0000000000..bc791638a1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java
@@ -0,0 +1,247 @@
+package org.citra.citra_emu.adapters;
+
+import android.database.Cursor;
+import android.database.DataSetObserver;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.os.SystemClock;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.FragmentActivity;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.ui.DividerItemDecoration;
+import org.citra.citra_emu.utils.Log;
+import org.citra.citra_emu.utils.PicassoUtils;
+import org.citra.citra_emu.viewholders.GameViewHolder;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.stream.Stream;
+
+/**
+ * This adapter gets its information from a database Cursor. This fact, paired with the usage of
+ * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly)
+ * large dataset.
+ */
+public final class GameAdapter extends RecyclerView.Adapter implements
+ View.OnClickListener {
+ private Cursor mCursor;
+ private GameDataSetObserver mObserver;
+
+ private boolean mDatasetValid;
+ private long mLastClickTime = 0;
+
+ /**
+ * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will
+ * display no data until a Cursor is supplied by a CursorLoader.
+ */
+ public GameAdapter() {
+ mDatasetValid = false;
+ mObserver = new GameDataSetObserver();
+ }
+
+ /**
+ * Called by the LayoutManager when it is necessary to create a new view.
+ *
+ * @param parent The RecyclerView (I think?) the created view will be thrown into.
+ * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView.
+ * @return The created ViewHolder with references to all the child view's members.
+ */
+ @Override
+ public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ // Create a new view.
+ View gameCard = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.card_game, parent, false);
+
+ gameCard.setOnClickListener(this);
+
+ // Use that view to create a ViewHolder.
+ return new GameViewHolder(gameCard);
+ }
+
+ /**
+ * Called by the LayoutManager when a new view is not necessary because we can recycle
+ * an existing one (for example, if a view just scrolled onto the screen from the bottom, we
+ * can use the view that just scrolled off the top instead of inflating a new one.)
+ *
+ * @param holder A ViewHolder representing the view we're recycling.
+ * @param position The position of the 'new' view in the dataset.
+ */
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ @Override
+ public void onBindViewHolder(@NonNull GameViewHolder holder, int position) {
+ if (mDatasetValid) {
+ if (mCursor.moveToPosition(position)) {
+ PicassoUtils.loadGameIcon(holder.imageIcon,
+ mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
+
+ holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " "));
+ holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
+
+ final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
+ holder.textFileName.setText(gamePath.getFileName().toString());
+
+ // TODO These shouldn't be necessary once the move to a DB-based model is complete.
+ holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID);
+ holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH);
+ holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE);
+ holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION);
+ holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS);
+ holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY);
+
+ final int backgroundColorId = isValidGame(holder.path) ? R.color.card_view_background : R.color.card_view_disabled;
+ View itemView = holder.getItemView();
+ itemView.setBackgroundColor(ContextCompat.getColor(itemView.getContext(), backgroundColorId));
+ } else {
+ Log.error("[GameAdapter] Can't bind view; Cursor is not valid.");
+ }
+ } else {
+ Log.error("[GameAdapter] Can't bind view; dataset is not valid.");
+ }
+ }
+
+ /**
+ * Called by the LayoutManager to find out how much data we have.
+ *
+ * @return Size of the dataset.
+ */
+ @Override
+ public int getItemCount() {
+ if (mDatasetValid && mCursor != null) {
+ return mCursor.getCount();
+ }
+ Log.error("[GameAdapter] Dataset is not valid.");
+ return 0;
+ }
+
+ /**
+ * Return the contents of the _id column for a given row.
+ *
+ * @param position The row for which Android wants an ID.
+ * @return A valid ID from the database, or 0 if not available.
+ */
+ @Override
+ public long getItemId(int position) {
+ if (mDatasetValid && mCursor != null) {
+ if (mCursor.moveToPosition(position)) {
+ return mCursor.getLong(GameDatabase.COLUMN_DB_ID);
+ }
+ }
+
+ Log.error("[GameAdapter] Dataset is not valid.");
+ return 0;
+ }
+
+ /**
+ * Tell Android whether or not each item in the dataset has a stable identifier.
+ * Which it does, because it's a database, so always tell Android 'true'.
+ *
+ * @param hasStableIds ignored.
+ */
+ @Override
+ public void setHasStableIds(boolean hasStableIds) {
+ super.setHasStableIds(true);
+ }
+
+ /**
+ * When a load is finished, call this to replace the existing data with the newly-loaded
+ * data.
+ *
+ * @param cursor The newly-loaded Cursor.
+ */
+ public void swapCursor(Cursor cursor) {
+ // Sanity check.
+ if (cursor == mCursor) {
+ return;
+ }
+
+ // Before getting rid of the old cursor, disassociate it from the Observer.
+ final Cursor oldCursor = mCursor;
+ if (oldCursor != null && mObserver != null) {
+ oldCursor.unregisterDataSetObserver(mObserver);
+ }
+
+ mCursor = cursor;
+ if (mCursor != null) {
+ // Attempt to associate the new Cursor with the Observer.
+ if (mObserver != null) {
+ mCursor.registerDataSetObserver(mObserver);
+ }
+
+ mDatasetValid = true;
+ } else {
+ mDatasetValid = false;
+ }
+
+ notifyDataSetChanged();
+ }
+
+ /**
+ * Launches the game that was clicked on.
+ *
+ * @param view The card representing the game the user wants to play.
+ */
+ @Override
+ public void onClick(View view) {
+ // Double-click prevention, using threshold of 1000 ms
+ if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) {
+ return;
+ }
+ mLastClickTime = SystemClock.elapsedRealtime();
+
+ GameViewHolder holder = (GameViewHolder) view.getTag();
+
+ EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title);
+ }
+
+ public static class SpacesItemDecoration extends DividerItemDecoration {
+ private int space;
+
+ public SpacesItemDecoration(Drawable divider, int space) {
+ super(divider);
+ this.space = space;
+ }
+
+ @Override
+ public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
+ @NonNull RecyclerView.State state) {
+ outRect.left = 0;
+ outRect.right = 0;
+ outRect.bottom = space;
+ outRect.top = 0;
+ }
+ }
+
+ private boolean isValidGame(String path) {
+ return Stream.of(
+ ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix));
+ }
+
+ private final class GameDataSetObserver extends DataSetObserver {
+ @Override
+ public void onChanged() {
+ super.onChanged();
+
+ mDatasetValid = true;
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void onInvalidated() {
+ super.onInvalidated();
+
+ mDatasetValid = false;
+ notifyDataSetChanged();
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java
new file mode 100644
index 0000000000..3586a9b349
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java
@@ -0,0 +1,122 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.applets;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.os.Bundle;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Objects;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+public final class MiiSelector {
+ public static class MiiSelectorConfig implements java.io.Serializable {
+ public boolean enable_cancel_button;
+ public String title;
+ public long initially_selected_mii_index;
+ // List of Miis to display
+ public String[] mii_names;
+ }
+
+ public static class MiiSelectorData {
+ public long return_code;
+ public int index;
+
+ private MiiSelectorData(long return_code, int index) {
+ this.return_code = return_code;
+ this.index = index;
+ }
+ }
+
+ public static class MiiSelectorDialogFragment extends DialogFragment {
+ static MiiSelectorDialogFragment newInstance(MiiSelectorConfig config) {
+ MiiSelectorDialogFragment frag = new MiiSelectorDialogFragment();
+ Bundle args = new Bundle();
+ args.putSerializable("config", config);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Activity emulationActivity = Objects.requireNonNull(getActivity());
+
+ MiiSelectorConfig config =
+ Objects.requireNonNull((MiiSelectorConfig) Objects.requireNonNull(getArguments())
+ .getSerializable("config"));
+
+ // Note: we intentionally leave out the Standard Mii in the native code so that
+ // the string can get translated
+ ArrayList list = new ArrayList<>();
+ list.add(emulationActivity.getString(R.string.standard_mii));
+ list.addAll(Arrays.asList(config.mii_names));
+
+ final int initialIndex = config.initially_selected_mii_index < list.size()
+ ? (int) config.initially_selected_mii_index
+ : 0;
+ data.index = initialIndex;
+ AlertDialog.Builder builder =
+ new AlertDialog.Builder(emulationActivity)
+ .setTitle(config.title.isEmpty()
+ ? emulationActivity.getString(R.string.mii_selector)
+ : config.title)
+ .setSingleChoiceItems(list.toArray(new String[]{}), initialIndex,
+ (dialog, which) -> {
+ data.index = which;
+ })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ data.return_code = 0;
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+ });
+ if (config.enable_cancel_button) {
+ builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
+ data.return_code = 1;
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+ });
+ }
+ setCancelable(false);
+ return builder.create();
+ }
+ }
+
+ private static MiiSelectorData data;
+ private static final Object finishLock = new Object();
+
+ private static void ExecuteImpl(MiiSelectorConfig config) {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+
+ data = new MiiSelectorData(0, 0);
+
+ MiiSelectorDialogFragment fragment = MiiSelectorDialogFragment.newInstance(config);
+ fragment.show(emulationActivity.getSupportFragmentManager(), "mii_selector");
+ }
+
+ public static MiiSelectorData Execute(MiiSelectorConfig config) {
+ NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config));
+
+ synchronized (finishLock) {
+ try {
+ finishLock.wait();
+ } catch (Exception ignored) {
+ }
+ }
+
+ return data;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java
new file mode 100644
index 0000000000..7be5f6d977
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java
@@ -0,0 +1,264 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.applets;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.text.InputFilter;
+import android.text.Spanned;
+import android.view.ViewGroup;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.utils.Log;
+
+import java.util.Objects;
+
+public final class SoftwareKeyboard {
+ /// Corresponds to Frontend::ButtonConfig
+ private interface ButtonConfig {
+ int Single = 0; /// Ok button
+ int Dual = 1; /// Cancel | Ok buttons
+ int Triple = 2; /// Cancel | I Forgot | Ok buttons
+ int None = 3; /// No button (returned by swkbdInputText in special cases)
+ }
+
+ /// Corresponds to Frontend::ValidationError
+ public enum ValidationError {
+ None,
+ // Button Selection
+ ButtonOutOfRange,
+ // Configured Filters
+ MaxDigitsExceeded,
+ AtSignNotAllowed,
+ PercentNotAllowed,
+ BackslashNotAllowed,
+ ProfanityNotAllowed,
+ CallbackFailed,
+ // Allowed Input Type
+ FixedLengthRequired,
+ MaxLengthExceeded,
+ BlankInputNotAllowed,
+ EmptyInputNotAllowed,
+ }
+
+ public static class KeyboardConfig implements java.io.Serializable {
+ public int button_config;
+ public int max_text_length;
+ public boolean multiline_mode; /// True if the keyboard accepts multiple lines of input
+ public String hint_text; /// Displayed in the field as a hint before
+ @Nullable
+ public String[] button_text; /// Contains the button text that the caller provides
+ }
+
+ /// Corresponds to Frontend::KeyboardData
+ public static class KeyboardData {
+ public int button;
+ public String text;
+
+ private KeyboardData(int button, String text) {
+ this.button = button;
+ this.text = text;
+ }
+ }
+
+ private static class Filter implements InputFilter {
+ @Override
+ public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
+ int dstart, int dend) {
+ String text = new StringBuilder(dest)
+ .replace(dstart, dend, source.subSequence(start, end).toString())
+ .toString();
+ if (ValidateFilters(text) == ValidationError.None) {
+ return null; // Accept replacement
+ }
+ return dest.subSequence(dstart, dend); // Request the subsequence to be unchanged
+ }
+ }
+
+ public static class KeyboardDialogFragment extends DialogFragment {
+ static KeyboardDialogFragment newInstance(KeyboardConfig config) {
+ KeyboardDialogFragment frag = new KeyboardDialogFragment();
+ Bundle args = new Bundle();
+ args.putSerializable("config", config);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Activity emulationActivity = getActivity();
+ assert emulationActivity != null;
+
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ params.leftMargin = params.rightMargin =
+ CitraApplication.getAppContext().getResources().getDimensionPixelSize(
+ R.dimen.dialog_margin);
+
+ KeyboardConfig config = Objects.requireNonNull(
+ (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config"));
+
+ // Set up the input
+ EditText editText = new EditText(CitraApplication.getAppContext());
+ editText.setHint(config.hint_text);
+ editText.setSingleLine(!config.multiline_mode);
+ editText.setLayoutParams(params);
+ editText.setFilters(new InputFilter[]{
+ new Filter(), new InputFilter.LengthFilter(config.max_text_length)});
+
+ FrameLayout container = new FrameLayout(emulationActivity);
+ container.addView(editText);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity)
+ .setTitle(R.string.software_keyboard)
+ .setView(container);
+ setCancelable(false);
+
+ switch (config.button_config) {
+ case ButtonConfig.Triple: {
+ final String text = config.button_text[1].isEmpty()
+ ? emulationActivity.getString(R.string.i_forgot)
+ : config.button_text[1];
+ builder.setNeutralButton(text, null);
+ }
+ // fallthrough
+ case ButtonConfig.Dual: {
+ final String text = config.button_text[0].isEmpty()
+ ? emulationActivity.getString(android.R.string.cancel)
+ : config.button_text[0];
+ builder.setNegativeButton(text, null);
+ }
+ // fallthrough
+ case ButtonConfig.Single: {
+ final String text = config.button_text[2].isEmpty()
+ ? emulationActivity.getString(android.R.string.ok)
+ : config.button_text[2];
+ builder.setPositiveButton(text, null);
+ break;
+ }
+ }
+
+ final AlertDialog dialog = builder.create();
+ dialog.create();
+ if (dialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) {
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener((view) -> {
+ data.button = config.button_config;
+ data.text = editText.getText().toString();
+ final ValidationError error = ValidateInput(data.text);
+ if (error != ValidationError.None) {
+ HandleValidationError(config, error);
+ return;
+ }
+
+ dialog.dismiss();
+
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+ });
+ }
+ if (dialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) {
+ dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener((view) -> {
+ data.button = 1;
+ dialog.dismiss();
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+ });
+ }
+ if (dialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) {
+ dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((view) -> {
+ data.button = 0;
+ dialog.dismiss();
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+ });
+ }
+
+ return dialog;
+ }
+ }
+
+ private static KeyboardData data;
+ private static final Object finishLock = new Object();
+
+ private static void ExecuteImpl(KeyboardConfig config) {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+
+ data = new KeyboardData(0, "");
+
+ KeyboardDialogFragment fragment = KeyboardDialogFragment.newInstance(config);
+ fragment.show(emulationActivity.getSupportFragmentManager(), "keyboard");
+ }
+
+ private static void HandleValidationError(KeyboardConfig config, ValidationError error) {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+ String message = "";
+ switch (error) {
+ case FixedLengthRequired:
+ message =
+ emulationActivity.getString(R.string.fixed_length_required, config.max_text_length);
+ break;
+ case MaxLengthExceeded:
+ message =
+ emulationActivity.getString(R.string.max_length_exceeded, config.max_text_length);
+ break;
+ case BlankInputNotAllowed:
+ message = emulationActivity.getString(R.string.blank_input_not_allowed);
+ break;
+ case EmptyInputNotAllowed:
+ message = emulationActivity.getString(R.string.empty_input_not_allowed);
+ break;
+ }
+
+ new AlertDialog.Builder(emulationActivity)
+ .setTitle(R.string.software_keyboard)
+ .setMessage(message)
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+ }
+
+ public static KeyboardData Execute(KeyboardConfig config) {
+ if (config.button_config == ButtonConfig.None) {
+ Log.error("Unexpected button config None");
+ return new KeyboardData(0, "");
+ }
+
+ NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config));
+
+ synchronized (finishLock) {
+ try {
+ finishLock.wait();
+ } catch (Exception ignored) {
+ }
+ }
+
+ return data;
+ }
+
+ public static void ShowError(String error) {
+ NativeLibrary.displayAlertMsg(
+ CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard),
+ error, false);
+ }
+
+ private static native ValidationError ValidateFilters(String text);
+
+ private static native ValidationError ValidateInput(String text);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java
new file mode 100644
index 0000000000..701cb07104
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java
@@ -0,0 +1,65 @@
+// Copyright 2020 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.camera;
+
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.provider.MediaStore;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.utils.PicassoUtils;
+
+import androidx.annotation.Nullable;
+
+// Used in native code.
+public final class StillImageCameraHelper {
+ public static final int REQUEST_CAMERA_FILE_PICKER = 1;
+ private static final Object filePickerLock = new Object();
+ private static @Nullable
+ String filePickerPath;
+
+ // Opens file picker for camera.
+ public static @Nullable
+ String OpenFilePicker() {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+
+ // At this point, we are assuming that we already have permissions as they are
+ // needed to launch a game
+ emulationActivity.runOnUiThread(() -> {
+ Intent intent = new Intent(Intent.ACTION_PICK);
+ intent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*");
+ emulationActivity.startActivityForResult(
+ Intent.createChooser(intent,
+ emulationActivity.getString(R.string.camera_select_image)),
+ REQUEST_CAMERA_FILE_PICKER);
+ });
+
+ synchronized (filePickerLock) {
+ try {
+ filePickerLock.wait();
+ } catch (InterruptedException ignored) {
+ }
+ }
+
+ return filePickerPath;
+ }
+
+ // Called from EmulationActivity.
+ public static void OnFilePickerResult(Intent result) {
+ filePickerPath = result == null ? null : result.getDataString();
+
+ synchronized (filePickerLock) {
+ filePickerLock.notifyAll();
+ }
+ }
+
+ // Blocking call. Load image from file and crop/resize it to fit in width x height.
+ @Nullable
+ public static Bitmap LoadImageFromFile(String uri, int width, int height) {
+ return PicassoUtils.LoadBitmapFromFile(uri, width, height);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java
new file mode 100644
index 0000000000..0f10f1858e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java
@@ -0,0 +1,140 @@
+package org.citra.citra_emu.dialogs;
+
+import android.content.Context;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+
+import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
+import org.citra.citra_emu.utils.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * {@link AlertDialog} derivative that listens for
+ * motion events from controllers and joysticks.
+ */
+public final class MotionAlertDialog extends AlertDialog {
+ // The selected input preference
+ private final InputBindingSetting setting;
+ private final ArrayList mPreviousValues = new ArrayList<>();
+ private int mPrevDeviceId = 0;
+ private boolean mWaitingForEvent = true;
+
+ /**
+ * Constructor
+ *
+ * @param context The current {@link Context}.
+ * @param setting The Preference to show this dialog for.
+ */
+ public MotionAlertDialog(Context context, InputBindingSetting setting) {
+ super(context);
+
+ this.setting = setting;
+ }
+
+ public boolean onKeyEvent(int keyCode, KeyEvent event) {
+ Log.debug("[MotionAlertDialog] Received key event: " + event.getAction());
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_UP:
+ setting.onKeyInput(event);
+ dismiss();
+ // Even if we ignore the key, we still consume it. Thus return true regardless.
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public boolean onKeyLongPress(int keyCode, @NonNull KeyEvent event) {
+ return super.onKeyLongPress(keyCode, event);
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ // Handle this key if we care about it, otherwise pass it down the framework
+ return onKeyEvent(event.getKeyCode(), event) || super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) {
+ // Handle this event if we care about it, otherwise pass it down the framework
+ return onMotionEvent(event) || super.dispatchGenericMotionEvent(event);
+ }
+
+ private boolean onMotionEvent(MotionEvent event) {
+ if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)
+ return false;
+ if (event.getAction() != MotionEvent.ACTION_MOVE)
+ return false;
+
+ InputDevice input = event.getDevice();
+
+ List motionRanges = input.getMotionRanges();
+
+ if (input.getId() != mPrevDeviceId) {
+ mPreviousValues.clear();
+ }
+ mPrevDeviceId = input.getId();
+ boolean firstEvent = mPreviousValues.isEmpty();
+
+ int numMovedAxis = 0;
+ float axisMoveValue = 0.0f;
+ InputDevice.MotionRange lastMovedRange = null;
+ char lastMovedDir = '?';
+ if (mWaitingForEvent) {
+ for (int i = 0; i < motionRanges.size(); i++) {
+ InputDevice.MotionRange range = motionRanges.get(i);
+ int axis = range.getAxis();
+ float origValue = event.getAxisValue(axis);
+ float value = origValue;//ControllerMappingHelper.scaleAxis(input, axis, origValue);
+ if (firstEvent) {
+ mPreviousValues.add(value);
+ } else {
+ float previousValue = mPreviousValues.get(i);
+
+ // Only handle the axes that are not neutral (more than 0.5)
+ // but ignore any axis that has a constant value (e.g. always 1)
+ if (Math.abs(value) > 0.5f && value != previousValue) {
+ // It is common to have multiple axes with the same physical input. For example,
+ // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE.
+ // To handle this, we ignore an axis motion that's the exact same as a motion
+ // we already saw. This way, we ignore axes with two names, but catch the case
+ // where a joystick is moved in two directions.
+ // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html
+ if (value != axisMoveValue) {
+ axisMoveValue = value;
+ numMovedAxis++;
+ lastMovedRange = range;
+ lastMovedDir = value < 0.0f ? '-' : '+';
+ }
+ }
+ // Special case for d-pads (axis value jumps between 0 and 1 without any values
+ // in between). Without this, the user would need to press the d-pad twice
+ // due to the first press being caught by the "if (firstEvent)" case further up.
+ else if (Math.abs(value) < 0.25f && Math.abs(previousValue) > 0.75f) {
+ numMovedAxis++;
+ lastMovedRange = range;
+ lastMovedDir = previousValue < 0.0f ? '-' : '+';
+ }
+ }
+
+ mPreviousValues.set(i, value);
+ }
+
+ // If only one axis moved, that's the winner.
+ if (numMovedAxis == 1) {
+ mWaitingForEvent = false;
+ setting.onMotionInput(input, lastMovedRange, lastMovedDir);
+ dismiss();
+ }
+ }
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java
new file mode 100644
index 0000000000..d6d14cc5f3
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java
@@ -0,0 +1,138 @@
+// Copyright 2021 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+package org.citra.citra_emu.disk_shader_cache;
+
+import android.app.Activity;
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.utils.Log;
+
+import java.util.Objects;
+
+public class DiskShaderCacheProgress {
+
+ // Equivalent to VideoCore::LoadCallbackStage
+ public enum LoadCallbackStage {
+ Prepare,
+ Decompile,
+ Build,
+ Complete,
+ }
+
+ private static final Object finishLock = new Object();
+ private static ProgressDialogFragment fragment;
+
+ public static class ProgressDialogFragment extends DialogFragment {
+ ProgressBar progressBar;
+ TextView progressText;
+ AlertDialog dialog;
+
+ static ProgressDialogFragment newInstance(String title, String message) {
+ ProgressDialogFragment frag = new ProgressDialogFragment();
+ Bundle args = new Bundle();
+ args.putString("title", title);
+ args.putString("message", message);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ final Activity emulationActivity = Objects.requireNonNull(getActivity());
+
+ final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title"));
+ final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message"));
+
+ LayoutInflater inflater = LayoutInflater.from(emulationActivity);
+ View view = inflater.inflate(R.layout.dialog_progress_bar, null);
+
+ progressBar = view.findViewById(R.id.progress_bar);
+ progressText = view.findViewById(R.id.progress_text);
+ progressText.setText("");
+
+ setCancelable(false);
+ setRetainInstance(true);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity);
+ builder.setTitle(title);
+ builder.setMessage(message);
+ builder.setView(view);
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ dialog = builder.create();
+ dialog.create();
+
+ dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v) -> emulationActivity.onBackPressed());
+
+ synchronized (finishLock) {
+ finishLock.notifyAll();
+ }
+
+ return dialog;
+ }
+
+ private void onUpdateProgress(String msg, int progress, int max) {
+ Objects.requireNonNull(getActivity()).runOnUiThread(() -> {
+ progressBar.setProgress(progress);
+ progressBar.setMax(max);
+ progressText.setText(String.format("%d/%d", progress, max));
+ dialog.setMessage(msg);
+ });
+ }
+ }
+
+ private static void prepareDialog() {
+ NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+ fragment = ProgressDialogFragment.newInstance(emulationActivity.getString(R.string.loading), emulationActivity.getString(R.string.preparing_shaders));
+ fragment.show(emulationActivity.getSupportFragmentManager(), "diskShaders");
+ });
+
+ synchronized (finishLock) {
+ try {
+ finishLock.wait();
+ } catch (Exception ignored) {
+ }
+ }
+ }
+
+ public static void loadProgress(LoadCallbackStage stage, int progress, int max) {
+ final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get();
+ if (emulationActivity == null) {
+ Log.error("[DiskShaderCacheProgress] EmulationActivity not present");
+ return;
+ }
+
+ switch (stage) {
+ case Prepare:
+ prepareDialog();
+ break;
+ case Decompile:
+ fragment.onUpdateProgress(emulationActivity.getString(R.string.preparing_shaders), progress, max);
+ break;
+ case Build:
+ fragment.onUpdateProgress(emulationActivity.getString(R.string.building_shaders), progress, max);
+ break;
+ case Complete:
+ // Workaround for when dialog is dismissed when the app is in the background
+ fragment.dismissAllowingStateLoss();
+ break;
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java
new file mode 100644
index 0000000000..93b0263640
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java
@@ -0,0 +1,57 @@
+package org.citra.citra_emu.features.cheats.model;
+
+import androidx.annotation.Keep;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class Cheat {
+ @Keep
+ private final long mPointer;
+
+ private Runnable mEnabledChangedCallback = null;
+
+ @Keep
+ private Cheat(long pointer) {
+ mPointer = pointer;
+ }
+
+ @Override
+ protected native void finalize();
+
+ @NonNull
+ public native String getName();
+
+ @NonNull
+ public native String getNotes();
+
+ @NonNull
+ public native String getCode();
+
+ public native boolean getEnabled();
+
+ public void setEnabled(boolean enabled) {
+ setEnabledImpl(enabled);
+ onEnabledChanged();
+ }
+
+ private native void setEnabledImpl(boolean enabled);
+
+ public void setEnabledChangedCallback(@Nullable Runnable callback) {
+ mEnabledChangedCallback = callback;
+ }
+
+ private void onEnabledChanged() {
+ if (mEnabledChangedCallback != null) {
+ mEnabledChangedCallback.run();
+ }
+ }
+
+ /**
+ * If the code is valid, returns 0. Otherwise, returns the 1-based index
+ * for the line containing the error.
+ */
+ public static native int isValidGatewayCode(@NonNull String code);
+
+ public static native Cheat createGatewayCode(@NonNull String name, @NonNull String notes,
+ @NonNull String code);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java
new file mode 100644
index 0000000000..5748162bb1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java
@@ -0,0 +1,13 @@
+package org.citra.citra_emu.features.cheats.model;
+
+public class CheatEngine {
+ public static native Cheat[] getCheats();
+
+ public static native void addCheat(Cheat cheat);
+
+ public static native void removeCheat(int index);
+
+ public static native void updateCheat(int index, Cheat newCheat);
+
+ public static native void saveCheatFile();
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java
new file mode 100644
index 0000000000..66f4202d83
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java
@@ -0,0 +1,177 @@
+package org.citra.citra_emu.features.cheats.model;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+public class CheatsViewModel extends ViewModel {
+ private int mSelectedCheatPosition = -1;
+ private final MutableLiveData mSelectedCheat = new MutableLiveData<>(null);
+ private final MutableLiveData mIsAdding = new MutableLiveData<>(false);
+ private final MutableLiveData mIsEditing = new MutableLiveData<>(false);
+
+ private final MutableLiveData mCheatAddedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mCheatChangedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mCheatDeletedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mOpenDetailsViewEvent = new MutableLiveData<>(false);
+
+ private Cheat[] mCheats;
+ private boolean mCheatsNeedSaving = false;
+
+ public void load() {
+ mCheats = CheatEngine.getCheats();
+
+ for (int i = 0; i < mCheats.length; i++) {
+ int position = i;
+ mCheats[i].setEnabledChangedCallback(() -> {
+ mCheatsNeedSaving = true;
+ notifyCheatUpdated(position);
+ });
+ }
+ }
+
+ public void saveIfNeeded() {
+ if (mCheatsNeedSaving) {
+ CheatEngine.saveCheatFile();
+ mCheatsNeedSaving = false;
+ }
+ }
+
+ public Cheat[] getCheats() {
+ return mCheats;
+ }
+
+ public LiveData getSelectedCheat() {
+ return mSelectedCheat;
+ }
+
+ public void setSelectedCheat(Cheat cheat, int position) {
+ if (mIsEditing.getValue()) {
+ setIsEditing(false);
+ }
+
+ mSelectedCheat.setValue(cheat);
+ mSelectedCheatPosition = position;
+ }
+
+ public LiveData getIsAdding() {
+ return mIsAdding;
+ }
+
+ public LiveData getIsEditing() {
+ return mIsEditing;
+ }
+
+ public void setIsEditing(boolean isEditing) {
+ mIsEditing.setValue(isEditing);
+
+ if (mIsAdding.getValue() && !isEditing) {
+ mIsAdding.setValue(false);
+ setSelectedCheat(null, -1);
+ }
+ }
+
+ /**
+ * When a cheat is added, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatAddedEvent() {
+ return mCheatAddedEvent;
+ }
+
+ private void notifyCheatAdded(int position) {
+ mCheatAddedEvent.setValue(position);
+ mCheatAddedEvent.setValue(null);
+ }
+
+ public void startAddingCheat() {
+ mSelectedCheat.setValue(null);
+ mSelectedCheatPosition = -1;
+
+ mIsAdding.setValue(true);
+ mIsEditing.setValue(true);
+ }
+
+ public void finishAddingCheat(Cheat cheat) {
+ if (!mIsAdding.getValue()) {
+ throw new IllegalStateException();
+ }
+
+ mIsAdding.setValue(false);
+ mIsEditing.setValue(false);
+
+ int position = mCheats.length;
+
+ CheatEngine.addCheat(cheat);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatAdded(position);
+ setSelectedCheat(mCheats[position], position);
+ }
+
+ /**
+ * When a cheat is edited, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatUpdatedEvent() {
+ return mCheatChangedEvent;
+ }
+
+ /**
+ * Notifies that an edit has been made to the contents of the cheat at the given position.
+ */
+ private void notifyCheatUpdated(int position) {
+ mCheatChangedEvent.setValue(position);
+ mCheatChangedEvent.setValue(null);
+ }
+
+ public void updateSelectedCheat(Cheat newCheat) {
+ CheatEngine.updateCheat(mSelectedCheatPosition, newCheat);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatUpdated(mSelectedCheatPosition);
+ setSelectedCheat(mCheats[mSelectedCheatPosition], mSelectedCheatPosition);
+ }
+
+ /**
+ * When a cheat is deleted, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatDeletedEvent() {
+ return mCheatDeletedEvent;
+ }
+
+ /**
+ * Notifies that the cheat at the given position has been deleted.
+ */
+ private void notifyCheatDeleted(int position) {
+ mCheatDeletedEvent.setValue(position);
+ mCheatDeletedEvent.setValue(null);
+ }
+
+ public void deleteSelectedCheat() {
+ int position = mSelectedCheatPosition;
+
+ setSelectedCheat(null, -1);
+
+ CheatEngine.removeCheat(position);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatDeleted(position);
+ }
+
+ public LiveData getOpenDetailsViewEvent() {
+ return mOpenDetailsViewEvent;
+ }
+
+ public void openDetailsView() {
+ mOpenDetailsViewEvent.setValue(true);
+ mOpenDetailsViewEvent.setValue(false);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java
new file mode 100644
index 0000000000..762cdb80e6
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java
@@ -0,0 +1,174 @@
+package org.citra.citra_emu.features.cheats.ui;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.model.Cheat;
+import org.citra.citra_emu.features.cheats.model.CheatsViewModel;
+
+public class CheatDetailsFragment extends Fragment {
+ private View mRoot;
+ private ScrollView mScrollView;
+ private TextView mLabelName;
+ private EditText mEditName;
+ private EditText mEditNotes;
+ private EditText mEditCode;
+ private Button mButtonDelete;
+ private Button mButtonEdit;
+ private Button mButtonCancel;
+ private Button mButtonOk;
+
+ private CheatsViewModel mViewModel;
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_cheat_details, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ mRoot = view.findViewById(R.id.root);
+ mScrollView = view.findViewById(R.id.scroll_view);
+ mLabelName = view.findViewById(R.id.label_name);
+ mEditName = view.findViewById(R.id.edit_name);
+ mEditNotes = view.findViewById(R.id.edit_notes);
+ mEditCode = view.findViewById(R.id.edit_code);
+ mButtonDelete = view.findViewById(R.id.button_delete);
+ mButtonEdit = view.findViewById(R.id.button_edit);
+ mButtonCancel = view.findViewById(R.id.button_cancel);
+ mButtonOk = view.findViewById(R.id.button_ok);
+
+ CheatsActivity activity = (CheatsActivity) requireActivity();
+ mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class);
+
+ mViewModel.getSelectedCheat().observe(getViewLifecycleOwner(),
+ this::onSelectedCheatUpdated);
+ mViewModel.getIsEditing().observe(getViewLifecycleOwner(), this::onIsEditingUpdated);
+
+ mButtonDelete.setOnClickListener(this::onDeleteClicked);
+ mButtonEdit.setOnClickListener(this::onEditClicked);
+ mButtonCancel.setOnClickListener(this::onCancelClicked);
+ mButtonOk.setOnClickListener(this::onOkClicked);
+
+ // On a portrait phone screen (or other narrow screen), only one of the two panes are shown
+ // at the same time. If the user is navigating using a d-pad and moves focus to an element
+ // in the currently hidden pane, we need to manually show that pane.
+ CheatsActivity.setOnFocusChangeListenerRecursively(view,
+ (v, hasFocus) -> activity.onDetailsViewFocusChange(hasFocus));
+ }
+
+ private void clearEditErrors() {
+ mEditName.setError(null);
+ mEditCode.setError(null);
+ }
+
+ private void onDeleteClicked(View view) {
+ String name = mEditName.getText().toString();
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(requireContext());
+ builder.setMessage(getString(R.string.cheats_delete_confirmation, name));
+ builder.setPositiveButton(android.R.string.yes,
+ (dialog, i) -> mViewModel.deleteSelectedCheat());
+ builder.setNegativeButton(android.R.string.no, null);
+ builder.show();
+ }
+
+ private void onEditClicked(View view) {
+ mViewModel.setIsEditing(true);
+ mButtonOk.requestFocus();
+ }
+
+ private void onCancelClicked(View view) {
+ mViewModel.setIsEditing(false);
+ onSelectedCheatUpdated(mViewModel.getSelectedCheat().getValue());
+ mButtonDelete.requestFocus();
+ }
+
+ private void onOkClicked(View view) {
+ clearEditErrors();
+
+ String name = mEditName.getText().toString();
+ String notes = mEditNotes.getText().toString();
+ String code = mEditCode.getText().toString();
+
+ if (name.isEmpty()) {
+ mEditName.setError(getString(R.string.cheats_error_no_name));
+ mScrollView.smoothScrollTo(0, mLabelName.getTop());
+ return;
+ } else if (code.isEmpty()) {
+ mEditCode.setError(getString(R.string.cheats_error_no_code_lines));
+ mScrollView.smoothScrollTo(0, mEditCode.getBottom());
+ return;
+ }
+
+ int validityResult = Cheat.isValidGatewayCode(code);
+
+ if (validityResult != 0) {
+ mEditCode.setError(getString(R.string.cheats_error_on_line, validityResult));
+ mScrollView.smoothScrollTo(0, mEditCode.getBottom());
+ return;
+ }
+
+ Cheat newCheat = Cheat.createGatewayCode(name, notes, code);
+
+ if (mViewModel.getIsAdding().getValue()) {
+ mViewModel.finishAddingCheat(newCheat);
+ } else {
+ mViewModel.updateSelectedCheat(newCheat);
+ }
+
+ mButtonEdit.requestFocus();
+ }
+
+ private void onSelectedCheatUpdated(@Nullable Cheat cheat) {
+ clearEditErrors();
+
+ boolean isEditing = mViewModel.getIsEditing().getValue();
+
+ mRoot.setVisibility(isEditing || cheat != null ? View.VISIBLE : View.GONE);
+
+ // If the fragment was recreated while editing a cheat, it's vital that we
+ // don't repopulate the fields, otherwise the user's changes will be lost
+ if (!isEditing) {
+ if (cheat == null) {
+ mEditName.setText("");
+ mEditNotes.setText("");
+ mEditCode.setText("");
+ } else {
+ mEditName.setText(cheat.getName());
+ mEditNotes.setText(cheat.getNotes());
+ mEditCode.setText(cheat.getCode());
+ }
+ }
+ }
+
+ private void onIsEditingUpdated(boolean isEditing) {
+ if (isEditing) {
+ mRoot.setVisibility(View.VISIBLE);
+ }
+
+ mEditName.setEnabled(isEditing);
+ mEditNotes.setEnabled(isEditing);
+ mEditCode.setEnabled(isEditing);
+
+ mButtonDelete.setVisibility(isEditing ? View.GONE : View.VISIBLE);
+ mButtonEdit.setVisibility(isEditing ? View.GONE : View.VISIBLE);
+ mButtonCancel.setVisibility(isEditing ? View.VISIBLE : View.GONE);
+ mButtonOk.setVisibility(isEditing ? View.VISIBLE : View.GONE);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java
new file mode 100644
index 0000000000..6c67a31d4e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java
@@ -0,0 +1,46 @@
+package org.citra.citra_emu.features.cheats.ui;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.model.CheatsViewModel;
+import org.citra.citra_emu.ui.DividerItemDecoration;
+
+public class CheatListFragment extends Fragment {
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_cheat_list, container, false);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ RecyclerView recyclerView = view.findViewById(R.id.cheat_list);
+ FloatingActionButton fab = view.findViewById(R.id.fab);
+
+ CheatsActivity activity = (CheatsActivity) requireActivity();
+ CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class);
+
+ recyclerView.setAdapter(new CheatsAdapter(activity, viewModel));
+ recyclerView.setLayoutManager(new LinearLayoutManager(activity));
+ recyclerView.addItemDecoration(new DividerItemDecoration(activity, null));
+
+ fab.setOnClickListener(v -> {
+ viewModel.startAddingCheat();
+ viewModel.openDetailsView();
+ });
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java
new file mode 100644
index 0000000000..8ba8f86e79
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java
@@ -0,0 +1,56 @@
+package org.citra.citra_emu.features.cheats.ui;
+
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.model.Cheat;
+import org.citra.citra_emu.features.cheats.model.CheatsViewModel;
+
+public class CheatViewHolder extends RecyclerView.ViewHolder
+ implements View.OnClickListener, CompoundButton.OnCheckedChangeListener {
+ private final View mRoot;
+ private final TextView mName;
+ private final CheckBox mCheckbox;
+
+ private CheatsViewModel mViewModel;
+ private Cheat mCheat;
+ private int mPosition;
+
+ public CheatViewHolder(@NonNull View itemView) {
+ super(itemView);
+
+ mRoot = itemView.findViewById(R.id.root);
+ mName = itemView.findViewById(R.id.text_name);
+ mCheckbox = itemView.findViewById(R.id.checkbox);
+ }
+
+ public void bind(CheatsActivity activity, Cheat cheat, int position) {
+ mCheckbox.setOnCheckedChangeListener(null);
+
+ mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class);
+ mCheat = cheat;
+ mPosition = position;
+
+ mName.setText(mCheat.getName());
+ mCheckbox.setChecked(mCheat.getEnabled());
+
+ mRoot.setOnClickListener(this);
+ mCheckbox.setOnCheckedChangeListener(this);
+ }
+
+ public void onClick(View root) {
+ mViewModel.setSelectedCheat(mCheat, mPosition);
+ mViewModel.openDetailsView();
+ }
+
+ public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
+ mCheat.setEnabled(isChecked);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java
new file mode 100644
index 0000000000..a36bf427ca
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java
@@ -0,0 +1,161 @@
+package org.citra.citra_emu.features.cheats.ui;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.view.ViewCompat;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.slidingpanelayout.widget.SlidingPaneLayout;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.model.Cheat;
+import org.citra.citra_emu.features.cheats.model.CheatsViewModel;
+import org.citra.citra_emu.ui.TwoPaneOnBackPressedCallback;
+
+public class CheatsActivity extends AppCompatActivity
+ implements SlidingPaneLayout.PanelSlideListener {
+ private CheatsViewModel mViewModel;
+
+ private SlidingPaneLayout mSlidingPaneLayout;
+ private View mCheatList;
+ private View mCheatDetails;
+
+ private View mCheatListLastFocus;
+ private View mCheatDetailsLastFocus;
+
+ public static void launch(Context context) {
+ Intent intent = new Intent(context, CheatsActivity.class);
+ context.startActivity(intent);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class);
+ mViewModel.load();
+
+ setContentView(R.layout.activity_cheats);
+
+ mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout);
+ mCheatList = findViewById(R.id.cheat_list);
+ mCheatDetails = findViewById(R.id.cheat_details);
+
+ mCheatListLastFocus = mCheatList;
+ mCheatDetailsLastFocus = mCheatDetails;
+
+ mSlidingPaneLayout.addPanelSlideListener(this);
+
+ getOnBackPressedDispatcher().addCallback(this,
+ new TwoPaneOnBackPressedCallback(mSlidingPaneLayout));
+
+ mViewModel.getSelectedCheat().observe(this, this::onSelectedCheatChanged);
+ mViewModel.getIsEditing().observe(this, this::onIsEditingChanged);
+ onSelectedCheatChanged(mViewModel.getSelectedCheat().getValue());
+
+ mViewModel.getOpenDetailsViewEvent().observe(this, this::openDetailsView);
+
+ // Show "Up" button in the action bar for navigation
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_settings, menu);
+
+ return true;
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+
+ mViewModel.saveIfNeeded();
+ }
+
+ @Override
+ public void onPanelSlide(@NonNull View panel, float slideOffset) {
+ }
+
+ @Override
+ public void onPanelOpened(@NonNull View panel) {
+ boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL;
+ mCheatDetailsLastFocus.requestFocus(rtl ? View.FOCUS_LEFT : View.FOCUS_RIGHT);
+ }
+
+ @Override
+ public void onPanelClosed(@NonNull View panel) {
+ boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL;
+ mCheatListLastFocus.requestFocus(rtl ? View.FOCUS_RIGHT : View.FOCUS_LEFT);
+ }
+
+ private void onIsEditingChanged(boolean isEditing) {
+ if (isEditing) {
+ mSlidingPaneLayout.setLockMode(SlidingPaneLayout.LOCK_MODE_UNLOCKED);
+ }
+ }
+
+ private void onSelectedCheatChanged(Cheat selectedCheat) {
+ boolean cheatSelected = selectedCheat != null || mViewModel.getIsEditing().getValue();
+
+ if (!cheatSelected && mSlidingPaneLayout.isOpen()) {
+ mSlidingPaneLayout.close();
+ }
+
+ mSlidingPaneLayout.setLockMode(cheatSelected ?
+ SlidingPaneLayout.LOCK_MODE_UNLOCKED : SlidingPaneLayout.LOCK_MODE_LOCKED_CLOSED);
+ }
+
+ public void onListViewFocusChange(boolean hasFocus) {
+ if (hasFocus) {
+ mCheatListLastFocus = mCheatList.findFocus();
+ if (mCheatListLastFocus == null)
+ throw new NullPointerException();
+
+ mSlidingPaneLayout.close();
+ }
+ }
+
+ public void onDetailsViewFocusChange(boolean hasFocus) {
+ if (hasFocus) {
+ mCheatDetailsLastFocus = mCheatDetails.findFocus();
+ if (mCheatDetailsLastFocus == null)
+ throw new NullPointerException();
+
+ mSlidingPaneLayout.open();
+ }
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ onBackPressed();
+ return true;
+ }
+
+ private void openDetailsView(boolean open) {
+ if (open) {
+ mSlidingPaneLayout.open();
+ }
+ }
+
+ public static void setOnFocusChangeListenerRecursively(@NonNull View view,
+ View.OnFocusChangeListener listener) {
+ view.setOnFocusChangeListener(listener);
+
+ if (view instanceof ViewGroup) {
+ ViewGroup viewGroup = (ViewGroup) view;
+ for (int i = 0; i < viewGroup.getChildCount(); i++) {
+ View child = viewGroup.getChildAt(i);
+ setOnFocusChangeListenerRecursively(child, listener);
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java
new file mode 100644
index 0000000000..9cb2ce8d8e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java
@@ -0,0 +1,72 @@
+package org.citra.citra_emu.features.cheats.ui;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.model.Cheat;
+import org.citra.citra_emu.features.cheats.model.CheatsViewModel;
+
+public class CheatsAdapter extends RecyclerView.Adapter {
+ private final CheatsActivity mActivity;
+ private final CheatsViewModel mViewModel;
+
+ public CheatsAdapter(CheatsActivity activity, CheatsViewModel viewModel) {
+ mActivity = activity;
+ mViewModel = viewModel;
+
+ mViewModel.getCheatAddedEvent().observe(activity, (position) -> {
+ if (position != null) {
+ notifyItemInserted(position);
+ }
+ });
+
+ mViewModel.getCheatUpdatedEvent().observe(activity, (position) -> {
+ if (position != null) {
+ notifyItemChanged(position);
+ }
+ });
+
+ mViewModel.getCheatDeletedEvent().observe(activity, (position) -> {
+ if (position != null) {
+ notifyItemRemoved(position);
+ }
+ });
+ }
+
+ @NonNull
+ @Override
+ public CheatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ View cheatView = inflater.inflate(R.layout.list_item_cheat, parent, false);
+ addViewListeners(cheatView);
+ return new CheatViewHolder(cheatView);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull CheatViewHolder holder, int position) {
+ holder.bind(mActivity, getItemAt(position), position);
+ }
+
+ @Override
+ public int getItemCount() {
+ return mViewModel.getCheats().length;
+ }
+
+ private void addViewListeners(View view) {
+ // On a portrait phone screen (or other narrow screen), only one of the two panes are shown
+ // at the same time. If the user is navigating using a d-pad and moves focus to an element
+ // in the currently hidden pane, we need to manually show that pane.
+ CheatsActivity.setOnFocusChangeListenerRecursively(view,
+ (v, hasFocus) -> mActivity.onListViewFocusChange(hasFocus));
+ }
+
+ private Cheat getItemAt(int position) {
+ return mViewModel.getCheats()[position];
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java
new file mode 100644
index 0000000000..932dcf1d32
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java
@@ -0,0 +1,23 @@
+package org.citra.citra_emu.features.settings.model;
+
+public final class BooleanSetting extends Setting {
+ private boolean mValue;
+
+ public BooleanSetting(String key, String section, boolean value) {
+ super(key, section);
+ mValue = value;
+ }
+
+ public boolean getValue() {
+ return mValue;
+ }
+
+ public void setValue(boolean value) {
+ mValue = value;
+ }
+
+ @Override
+ public String getValueAsString() {
+ return mValue ? "True" : "False";
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java
new file mode 100644
index 0000000000..275f0eceae
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java
@@ -0,0 +1,23 @@
+package org.citra.citra_emu.features.settings.model;
+
+public final class FloatSetting extends Setting {
+ private float mValue;
+
+ public FloatSetting(String key, String section, float value) {
+ super(key, section);
+ mValue = value;
+ }
+
+ public float getValue() {
+ return mValue;
+ }
+
+ public void setValue(float value) {
+ mValue = value;
+ }
+
+ @Override
+ public String getValueAsString() {
+ return Float.toString(mValue);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java
new file mode 100644
index 0000000000..f712e5bfa4
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java
@@ -0,0 +1,23 @@
+package org.citra.citra_emu.features.settings.model;
+
+public final class IntSetting extends Setting {
+ private int mValue;
+
+ public IntSetting(String key, String section, int value) {
+ super(key, section);
+ mValue = value;
+ }
+
+ public int getValue() {
+ return mValue;
+ }
+
+ public void setValue(int value) {
+ mValue = value;
+ }
+
+ @Override
+ public String getValueAsString() {
+ return Integer.toString(mValue);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java
new file mode 100644
index 0000000000..b762847c94
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java
@@ -0,0 +1,42 @@
+package org.citra.citra_emu.features.settings.model;
+
+/**
+ * Abstraction for a setting item as read from / written to Citra's configuration ini files.
+ * These files generally consist of a key/value pair, though the type of value is ambiguous and
+ * must be inferred at read-time. The type of value determines which child of this class is used
+ * to represent the Setting.
+ */
+public abstract class Setting {
+ private String mKey;
+ private String mSection;
+
+ /**
+ * Base constructor.
+ *
+ * @param key Everything to the left of the = in a line from the ini file.
+ * @param section The corresponding recent section header; e.g. [Core] or [Enhancements] without the brackets.
+ */
+ public Setting(String key, String section) {
+ mKey = key;
+ mSection = section;
+ }
+
+ /**
+ * @return The identifier used to write this setting to the ini file.
+ */
+ public String getKey() {
+ return mKey;
+ }
+
+ /**
+ * @return The name of the header under which this Setting should be written in the ini file.
+ */
+ public String getSection() {
+ return mSection;
+ }
+
+ /**
+ * @return A representation of this Setting's backing value converted to a String (e.g. for serialization).
+ */
+ public abstract String getValueAsString();
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java
new file mode 100644
index 0000000000..0a291aa6bb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java
@@ -0,0 +1,55 @@
+package org.citra.citra_emu.features.settings.model;
+
+import java.util.HashMap;
+
+/**
+ * A semantically-related group of Settings objects. These Settings are
+ * internally stored as a HashMap.
+ */
+public final class SettingSection {
+ private String mName;
+
+ private HashMap mSettings = new HashMap<>();
+
+ /**
+ * Create a new SettingSection with no Settings in it.
+ *
+ * @param name The header of this section; e.g. [Core] or [Enhancements] without the brackets.
+ */
+ public SettingSection(String name) {
+ mName = name;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ /**
+ * Convenience method; inserts a value directly into the backing HashMap.
+ *
+ * @param setting The Setting to be inserted.
+ */
+ public void putSetting(Setting setting) {
+ mSettings.put(setting.getKey(), 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)
+ */
+ public Setting getSetting(String key) {
+ return mSettings.get(key);
+ }
+
+ public HashMap getSettings() {
+ return mSettings;
+ }
+
+ public void mergeSection(SettingSection settingSection) {
+ for (Setting setting : settingSection.mSettings.values()) {
+ putSetting(setting);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java
new file mode 100644
index 0000000000..9684966f20
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java
@@ -0,0 +1,132 @@
+package org.citra.citra_emu.features.settings.model;
+
+import android.text.TextUtils;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.ui.SettingsActivityView;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+public class Settings {
+ public static final String SECTION_PREMIUM = "Premium";
+ public static final String SECTION_CORE = "Core";
+ public static final String SECTION_SYSTEM = "System";
+ public static final String SECTION_CAMERA = "Camera";
+ public static final String SECTION_CONTROLS = "Controls";
+ public static final String SECTION_RENDERER = "Renderer";
+ public static final String SECTION_LAYOUT = "Layout";
+ public static final String SECTION_UTILITY = "Utility";
+ public static final String SECTION_AUDIO = "Audio";
+ public static final String SECTION_DEBUG = "Debug";
+
+ private String gameId;
+
+ private static final Map> configFileSectionsMap = new HashMap<>();
+
+ static {
+ configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG));
+ }
+
+ /**
+ * A HashMap that constructs a new SettingSection instead of returning null
+ * when getting a key not already in the map
+ */
+ public static final class SettingsSectionMap extends HashMap {
+ @Override
+ public SettingSection get(Object key) {
+ if (!(key instanceof String)) {
+ return null;
+ }
+
+ String stringKey = (String) key;
+
+ if (!super.containsKey(stringKey)) {
+ SettingSection section = new SettingSection(stringKey);
+ super.put(stringKey, section);
+ return section;
+ }
+ return super.get(key);
+ }
+ }
+
+ private HashMap sections = new Settings.SettingsSectionMap();
+
+ public SettingSection getSection(String sectionName) {
+ return sections.get(sectionName);
+ }
+
+ public boolean isEmpty() {
+ return sections.isEmpty();
+ }
+
+ public HashMap getSections() {
+ return sections;
+ }
+
+ public void loadSettings(SettingsActivityView view) {
+ sections = new Settings.SettingsSectionMap();
+ loadCitraSettings(view);
+
+ if (!TextUtils.isEmpty(gameId)) {
+ loadCustomGameSettings(gameId, view);
+ }
+ }
+
+ private void loadCitraSettings(SettingsActivityView view) {
+ for (Map.Entry> entry : configFileSectionsMap.entrySet()) {
+ String fileName = entry.getKey();
+ sections.putAll(SettingsFile.readFile(fileName, view));
+ }
+ }
+
+ private void loadCustomGameSettings(String gameId, SettingsActivityView view) {
+ // custom game settings
+ mergeSections(SettingsFile.readCustomGameSettings(gameId, view));
+ }
+
+ private void mergeSections(HashMap updatedSections) {
+ for (Map.Entry entry : updatedSections.entrySet()) {
+ if (sections.containsKey(entry.getKey())) {
+ SettingSection originalSection = sections.get(entry.getKey());
+ SettingSection updatedSection = entry.getValue();
+ originalSection.mergeSection(updatedSection);
+ } else {
+ sections.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+
+ public void loadSettings(String gameId, SettingsActivityView view) {
+ this.gameId = gameId;
+ loadSettings(view);
+ }
+
+ public void saveSettings(SettingsActivityView view) {
+ if (TextUtils.isEmpty(gameId)) {
+ view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false);
+
+ for (Map.Entry> entry : configFileSectionsMap.entrySet()) {
+ String fileName = entry.getKey();
+ List sectionNames = entry.getValue();
+ TreeMap iniSections = new TreeMap<>();
+ for (String section : sectionNames) {
+ iniSections.put(section, sections.get(section));
+ }
+
+ SettingsFile.saveFile(fileName, iniSections, view);
+ }
+ } else {
+ // custom game settings
+ view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false);
+
+ SettingsFile.saveCustomGameSettings(gameId, sections);
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java
new file mode 100644
index 0000000000..b906b70109
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java
@@ -0,0 +1,23 @@
+package org.citra.citra_emu.features.settings.model;
+
+public final class StringSetting extends Setting {
+ private String mValue;
+
+ public StringSetting(String key, String section, String value) {
+ super(key, section);
+ mValue = value;
+ }
+
+ public String getValue() {
+ return mValue;
+ }
+
+ public void setValue(String value) {
+ mValue = value;
+ }
+
+ @Override
+ public String getValueAsString() {
+ return mValue;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java
new file mode 100644
index 0000000000..baf40709fe
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java
@@ -0,0 +1,80 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.BooleanSetting;
+import org.citra.citra_emu.features.settings.model.IntSetting;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.ui.SettingsFragmentView;
+
+public final class CheckBoxSetting extends SettingsItem {
+ private boolean mDefaultValue;
+ private boolean mShowPerformanceWarning;
+ private SettingsFragmentView mView;
+
+ public CheckBoxSetting(String key, String section, int titleId, int descriptionId,
+ boolean defaultValue, Setting setting) {
+ super(key, section, setting, titleId, descriptionId);
+ mDefaultValue = defaultValue;
+ mShowPerformanceWarning = false;
+ }
+
+ public CheckBoxSetting(String key, String section, int titleId, int descriptionId,
+ boolean defaultValue, Setting setting, boolean show_performance_warning, SettingsFragmentView view) {
+ super(key, section, setting, titleId, descriptionId);
+ mDefaultValue = defaultValue;
+ mView = view;
+ mShowPerformanceWarning = show_performance_warning;
+ }
+
+ public boolean isChecked() {
+ if (getSetting() == null) {
+ return mDefaultValue;
+ }
+
+ // Try integer setting
+ try {
+ IntSetting setting = (IntSetting) getSetting();
+ return setting.getValue() == 1;
+ } catch (ClassCastException exception) {
+ }
+
+ // Try boolean setting
+ try {
+ BooleanSetting setting = (BooleanSetting) getSetting();
+ return setting.getValue() == true;
+ } catch (ClassCastException exception) {
+ }
+
+ return mDefaultValue;
+ }
+
+ /**
+ * 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 null if overwritten successfully; otherwise, a newly created BooleanSetting.
+ */
+ public IntSetting setChecked(boolean checked) {
+ // Show a performance warning if the setting has been disabled
+ if (mShowPerformanceWarning && !checked) {
+ mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true);
+ }
+
+ if (getSetting() == null) {
+ IntSetting setting = new IntSetting(getKey(), getSection(), checked ? 1 : 0);
+ setSetting(setting);
+ return setting;
+ } else {
+ IntSetting setting = (IntSetting) getSetting();
+ setting.setValue(checked ? 1 : 0);
+ return null;
+ }
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_CHECKBOX;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java
new file mode 100644
index 0000000000..afc3352cc0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java
@@ -0,0 +1,40 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+
+public final class DateTimeSetting extends SettingsItem {
+ private String mDefaultValue;
+
+ public DateTimeSetting(String key, String section, int titleId, int descriptionId,
+ String defaultValue, Setting setting) {
+ super(key, section, setting, titleId, descriptionId);
+ mDefaultValue = defaultValue;
+ }
+
+ public String getValue() {
+ if (getSetting() != null) {
+ StringSetting setting = (StringSetting) getSetting();
+ return setting.getValue();
+ } else {
+ return mDefaultValue;
+ }
+ }
+
+ public StringSetting setSelectedValue(String datetime) {
+ if (getSetting() == null) {
+ StringSetting setting = new StringSetting(getKey(), getSection(), datetime);
+ setSetting(setting);
+ return setting;
+ } else {
+ StringSetting setting = (StringSetting) getSetting();
+ setting.setValue(datetime);
+ return null;
+ }
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_DATETIME_SETTING;
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java
new file mode 100644
index 0000000000..bac8876cdf
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java
@@ -0,0 +1,14 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+
+public final class HeaderSetting extends SettingsItem {
+ public HeaderSetting(String key, Setting setting, int titleId, int descriptionId) {
+ super(key, null, setting, titleId, descriptionId);
+ }
+
+ @Override
+ public int getType() {
+ return SettingsItem.TYPE_HEADER;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java
new file mode 100644
index 0000000000..e9141a2080
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java
@@ -0,0 +1,382 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.widget.Toast;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+
+public final class InputBindingSetting extends SettingsItem {
+ private static final String INPUT_MAPPING_PREFIX = "InputMapping";
+
+ public InputBindingSetting(String key, String section, int titleId, Setting setting) {
+ super(key, section, setting, titleId, 0);
+ }
+
+ public String getValue() {
+ if (getSetting() == null) {
+ return "";
+ }
+
+ StringSetting setting = (StringSetting) getSetting();
+ return setting.getValue();
+ }
+
+ /**
+ * Returns true if this key is for the 3DS Circle Pad
+ */
+ private boolean IsCirclePad() {
+ switch (getKey()) {
+ case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL:
+ case SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL:
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad
+ */
+ public boolean IsHorizontalOrientation() {
+ switch (getKey()) {
+ case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL:
+ case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL:
+ case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL:
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this key is for the 3DS C-Stick
+ */
+ private boolean IsCStick() {
+ switch (getKey()) {
+ case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL:
+ case SettingsFile.KEY_CSTICK_AXIS_VERTICAL:
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this key is for the 3DS D-Pad
+ */
+ private boolean IsDPad() {
+ switch (getKey()) {
+ case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL:
+ case SettingsFile.KEY_DPAD_AXIS_VERTICAL:
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real
+ * triggers on the 3DS, but we support them as such on a physical gamepad.
+ */
+ public boolean IsTrigger() {
+ switch (getKey()) {
+ case SettingsFile.KEY_BUTTON_L:
+ case SettingsFile.KEY_BUTTON_R:
+ case SettingsFile.KEY_BUTTON_ZL:
+ case SettingsFile.KEY_BUTTON_ZR:
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if a gamepad axis can be used to map this key.
+ */
+ public boolean IsAxisMappingSupported() {
+ return IsCirclePad() || IsCStick() || IsDPad() || IsTrigger();
+ }
+
+ /**
+ * Returns true if a gamepad button can be used to map this key.
+ */
+ private boolean IsButtonMappingSupported() {
+ return !IsAxisMappingSupported() || IsTrigger();
+ }
+
+ /**
+ * Returns the Citra button code for the settings key.
+ */
+ private int getButtonCode() {
+ switch (getKey()) {
+ case SettingsFile.KEY_BUTTON_A:
+ return NativeLibrary.ButtonType.BUTTON_A;
+ case SettingsFile.KEY_BUTTON_B:
+ return NativeLibrary.ButtonType.BUTTON_B;
+ case SettingsFile.KEY_BUTTON_X:
+ return NativeLibrary.ButtonType.BUTTON_X;
+ case SettingsFile.KEY_BUTTON_Y:
+ return NativeLibrary.ButtonType.BUTTON_Y;
+ case SettingsFile.KEY_BUTTON_L:
+ return NativeLibrary.ButtonType.TRIGGER_L;
+ case SettingsFile.KEY_BUTTON_R:
+ return NativeLibrary.ButtonType.TRIGGER_R;
+ case SettingsFile.KEY_BUTTON_ZL:
+ return NativeLibrary.ButtonType.BUTTON_ZL;
+ case SettingsFile.KEY_BUTTON_ZR:
+ return NativeLibrary.ButtonType.BUTTON_ZR;
+ case SettingsFile.KEY_BUTTON_SELECT:
+ return NativeLibrary.ButtonType.BUTTON_SELECT;
+ case SettingsFile.KEY_BUTTON_START:
+ return NativeLibrary.ButtonType.BUTTON_START;
+ case SettingsFile.KEY_BUTTON_UP:
+ return NativeLibrary.ButtonType.DPAD_UP;
+ case SettingsFile.KEY_BUTTON_DOWN:
+ return NativeLibrary.ButtonType.DPAD_DOWN;
+ case SettingsFile.KEY_BUTTON_LEFT:
+ return NativeLibrary.ButtonType.DPAD_LEFT;
+ case SettingsFile.KEY_BUTTON_RIGHT:
+ return NativeLibrary.ButtonType.DPAD_RIGHT;
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the settings key for the specified Citra button code.
+ */
+ private static String getButtonKey(int buttonCode) {
+ switch (buttonCode) {
+ case NativeLibrary.ButtonType.BUTTON_A:
+ return SettingsFile.KEY_BUTTON_A;
+ case NativeLibrary.ButtonType.BUTTON_B:
+ return SettingsFile.KEY_BUTTON_B;
+ case NativeLibrary.ButtonType.BUTTON_X:
+ return SettingsFile.KEY_BUTTON_X;
+ case NativeLibrary.ButtonType.BUTTON_Y:
+ return SettingsFile.KEY_BUTTON_Y;
+ case NativeLibrary.ButtonType.TRIGGER_L:
+ return SettingsFile.KEY_BUTTON_L;
+ case NativeLibrary.ButtonType.TRIGGER_R:
+ return SettingsFile.KEY_BUTTON_R;
+ case NativeLibrary.ButtonType.BUTTON_ZL:
+ return SettingsFile.KEY_BUTTON_ZL;
+ case NativeLibrary.ButtonType.BUTTON_ZR:
+ return SettingsFile.KEY_BUTTON_ZR;
+ case NativeLibrary.ButtonType.BUTTON_SELECT:
+ return SettingsFile.KEY_BUTTON_SELECT;
+ case NativeLibrary.ButtonType.BUTTON_START:
+ return SettingsFile.KEY_BUTTON_START;
+ case NativeLibrary.ButtonType.DPAD_UP:
+ return SettingsFile.KEY_BUTTON_UP;
+ case NativeLibrary.ButtonType.DPAD_DOWN:
+ return SettingsFile.KEY_BUTTON_DOWN;
+ case NativeLibrary.ButtonType.DPAD_LEFT:
+ return SettingsFile.KEY_BUTTON_LEFT;
+ case NativeLibrary.ButtonType.DPAD_RIGHT:
+ return SettingsFile.KEY_BUTTON_RIGHT;
+ }
+ return "";
+ }
+
+ /**
+ * Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old
+ * settings on re-mapping or clearing of a setting.
+ */
+ private String getReverseKey() {
+ String reverseKey = INPUT_MAPPING_PREFIX + "_ReverseMapping_" + getKey();
+
+ if (IsAxisMappingSupported() && !IsTrigger()) {
+ // Triggers are the only axis-supported mappings without orientation
+ reverseKey += "_" + (IsHorizontalOrientation() ? 0 : 1);
+ }
+
+ return reverseKey;
+ }
+
+ /**
+ * Removes the old mapping for this key from the settings, e.g. on user clearing the setting.
+ */
+ public void removeOldMapping() {
+ // Get preferences editor
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences.Editor editor = preferences.edit();
+
+ // Try remove all possible keys we wrote for this setting
+ String oldKey = preferences.getString(getReverseKey(), "");
+ if (!oldKey.equals("")) {
+ editor.remove(getKey()); // Used for ui text
+ editor.remove(oldKey); // Used for button mapping
+ editor.remove(oldKey + "_GuestOrientation"); // Used for axis orientation
+ editor.remove(oldKey + "_GuestButton"); // Used for axis button
+ }
+
+ // Apply changes
+ editor.apply();
+ }
+
+ /**
+ * Helper function to get the settings key for an gamepad button.
+ */
+ public static String getInputButtonKey(int keyCode) {
+ return INPUT_MAPPING_PREFIX + "_Button_" + keyCode;
+ }
+
+ /**
+ * Helper function to get the settings key for an gamepad axis.
+ */
+ public static String getInputAxisKey(int axis) {
+ return INPUT_MAPPING_PREFIX + "_HostAxis_" + axis;
+ }
+
+ /**
+ * Helper function to get the settings key for an gamepad axis button (stick or trigger).
+ */
+ public static String getInputAxisButtonKey(int axis) {
+ return getInputAxisKey(axis) + "_GuestButton";
+ }
+
+ /**
+ * Helper function to get the settings key for an gamepad axis orientation.
+ */
+ public static String getInputAxisOrientationKey(int axis) {
+ return getInputAxisKey(axis) + "_GuestOrientation";
+ }
+
+ /**
+ * Helper function to write a gamepad button mapping for the setting.
+ */
+ private void WriteButtonMapping(String key) {
+ // Get preferences editor
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences.Editor editor = preferences.edit();
+
+ // Remove mapping for another setting using this input
+ int oldButtonCode = preferences.getInt(key, -1);
+ if (oldButtonCode != -1) {
+ String oldKey = getButtonKey(oldButtonCode);
+ editor.remove(oldKey); // Only need to remove UI text setting, others will be overwritten
+ }
+
+ // Cleanup old mapping for this setting
+ removeOldMapping();
+
+ // Write new mapping
+ editor.putInt(key, getButtonCode());
+
+ // Write next reverse mapping for future cleanup
+ editor.putString(getReverseKey(), key);
+
+ // Apply changes
+ editor.apply();
+ }
+
+ /**
+ * Helper function to write a gamepad axis mapping for the setting.
+ */
+ private void WriteAxisMapping(int axis, int value) {
+ // Get preferences editor
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences.Editor editor = preferences.edit();
+
+ // Cleanup old mapping
+ removeOldMapping();
+
+ // Write new mapping
+ editor.putInt(getInputAxisOrientationKey(axis), IsHorizontalOrientation() ? 0 : 1);
+ editor.putInt(getInputAxisButtonKey(axis), value);
+
+ // Write next reverse mapping for future cleanup
+ editor.putString(getReverseKey(), getInputAxisKey(axis));
+
+ // Apply changes
+ editor.apply();
+ }
+
+ /**
+ * Saves the provided key input setting as an Android preference.
+ *
+ * @param keyEvent KeyEvent of this key press.
+ */
+ public void onKeyInput(KeyEvent keyEvent) {
+ if (!IsButtonMappingSupported()) {
+ Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ InputDevice device = keyEvent.getDevice();
+
+ WriteButtonMapping(getInputButtonKey(keyEvent.getKeyCode()));
+
+ String uiString = device.getName() + ": Button " + keyEvent.getKeyCode();
+ setUiString(uiString);
+ }
+
+ /**
+ * Saves the provided motion input setting as an Android preference.
+ *
+ * @param device InputDevice from which the input event originated.
+ * @param motionRange MotionRange of the movement
+ * @param axisDir Either '-' or '+' (currently unused)
+ */
+ public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange,
+ char axisDir) {
+ if (!IsAxisMappingSupported()) {
+ Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences.Editor editor = preferences.edit();
+
+ int button;
+ if (IsCirclePad()) {
+ button = NativeLibrary.ButtonType.STICK_LEFT;
+ } else if (IsCStick()) {
+ button = NativeLibrary.ButtonType.STICK_C;
+ } else if (IsDPad()) {
+ button = NativeLibrary.ButtonType.DPAD;
+ } else {
+ button = getButtonCode();
+ }
+
+ WriteAxisMapping(motionRange.getAxis(), button);
+
+ String uiString = device.getName() + ": Axis " + motionRange.getAxis();
+ setUiString(uiString);
+
+ editor.apply();
+ }
+
+ /**
+ * Sets the string to use in the configuration UI for the gamepad input.
+ */
+ private StringSetting setUiString(String ui) {
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+ SharedPreferences.Editor editor = preferences.edit();
+
+ if (getSetting() == null) {
+ StringSetting setting = new StringSetting(getKey(), getSection(), "");
+ setSetting(setting);
+
+ editor.putString(setting.getKey(), ui);
+ editor.apply();
+
+ return setting;
+ } else {
+ StringSetting setting = (StringSetting) getSetting();
+
+ editor.putString(setting.getKey(), ui);
+ editor.apply();
+
+ return null;
+ }
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_INPUT_BINDING;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java
new file mode 100644
index 0000000000..2b1793d3e0
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java
@@ -0,0 +1,12 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+public final class PremiumHeader extends SettingsItem {
+ public PremiumHeader() {
+ super(null, null, null, 0, 0);
+ }
+
+ @Override
+ public int getType() {
+ return SettingsItem.TYPE_PREMIUM;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java
new file mode 100644
index 0000000000..c0560d2dc6
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java
@@ -0,0 +1,59 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.ui.SettingsFragmentView;
+
+public final class PremiumSingleChoiceSetting extends SettingsItem {
+ private int mDefaultValue;
+
+ private int mChoicesId;
+ private int mValuesId;
+ private SettingsFragmentView mView;
+
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId,
+ int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) {
+ super(key, section, setting, titleId, descriptionId);
+ mValuesId = valuesId;
+ mChoicesId = choicesId;
+ mDefaultValue = defaultValue;
+ mView = view;
+ }
+
+ public int getChoicesId() {
+ return mChoicesId;
+ }
+
+ public int getValuesId() {
+ return mValuesId;
+ }
+
+ public int getSelectedValue() {
+ return mPreferences.getInt(getKey(), mDefaultValue);
+ }
+
+ /**
+ * 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 null if overwritten successfully otherwise; a newly created IntSetting.
+ */
+ public void setSelectedValue(int selection) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putInt(getKey(), selection);
+ editor.apply();
+ mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false);
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_SINGLE_CHOICE;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java
new file mode 100644
index 0000000000..3053520223
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java
@@ -0,0 +1,107 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+/**
+ * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
+ * Each one corresponds to a {@link Setting} 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.)
+ */
+public abstract class SettingsItem {
+ public static final int TYPE_HEADER = 0;
+ public static final int TYPE_CHECKBOX = 1;
+ public static final int TYPE_SINGLE_CHOICE = 2;
+ public static final int TYPE_SLIDER = 3;
+ public static final int TYPE_SUBMENU = 4;
+ public static final int TYPE_INPUT_BINDING = 5;
+ public static final int TYPE_STRING_SINGLE_CHOICE = 6;
+ public static final int TYPE_DATETIME_SETTING = 7;
+ public static final int TYPE_PREMIUM = 8;
+
+ private String mKey;
+ private String mSection;
+
+ private Setting mSetting;
+
+ private int mNameId;
+ private int mDescriptionId;
+ private boolean mIsPremium;
+
+ /**
+ * Base constructor. Takes a key / section name in case the third parameter, the Setting,
+ * is null; in which case, one can be constructed and saved using the key / section.
+ *
+ * @param key Identifier for the Setting represented by this Item.
+ * @param section Section to which the Setting belongs.
+ * @param setting A possibly-null backing Setting, to be modified on UI events.
+ * @param nameId Resource ID for a text string to be displayed as this setting's name.
+ * @param descriptionId Resource ID for a text string to be displayed as this setting's description.
+ */
+ public SettingsItem(String key, String section, Setting setting, int nameId,
+ int descriptionId) {
+ mKey = key;
+ mSection = section;
+ mSetting = setting;
+ mNameId = nameId;
+ mDescriptionId = descriptionId;
+ mIsPremium = (section == Settings.SECTION_PREMIUM);
+ }
+
+ /**
+ * @return The identifier for the backing Setting.
+ */
+ public String getKey() {
+ return mKey;
+ }
+
+ /**
+ * @return The header under which the backing Setting belongs.
+ */
+ public String getSection() {
+ return mSection;
+ }
+
+ /**
+ * @return The backing Setting, possibly null.
+ */
+ public Setting getSetting() {
+ return mSetting;
+ }
+
+ /**
+ * Replace the backing setting with a new one. Generally used in cases where
+ * the backing setting is null.
+ *
+ * @param setting A non-null Setting.
+ */
+ public void setSetting(Setting setting) {
+ mSetting = setting;
+ }
+
+ /**
+ * @return A resource ID for a text string representing this Setting's name.
+ */
+ public int getNameId() {
+ return mNameId;
+ }
+
+ public int getDescriptionId() {
+ return mDescriptionId;
+ }
+
+ public boolean isPremium() {
+ return mIsPremium;
+ }
+
+ /**
+ * Used by {@link SettingsAdapter}'s onCreateViewHolder()
+ * method to determine which type of ViewHolder should be created.
+ *
+ * @return An integer (ideally, one of the constants defined in this file)
+ */
+ public abstract int getType();
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java
new file mode 100644
index 0000000000..ee9d225d60
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java
@@ -0,0 +1,60 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.IntSetting;
+import org.citra.citra_emu.features.settings.model.Setting;
+
+public final class SingleChoiceSetting extends SettingsItem {
+ private int mDefaultValue;
+
+ private int mChoicesId;
+ private int mValuesId;
+
+ public SingleChoiceSetting(String key, String section, int titleId, int descriptionId,
+ int choicesId, int valuesId, int defaultValue, Setting setting) {
+ super(key, section, setting, titleId, descriptionId);
+ mValuesId = valuesId;
+ mChoicesId = choicesId;
+ mDefaultValue = defaultValue;
+ }
+
+ public int getChoicesId() {
+ return mChoicesId;
+ }
+
+ public int getValuesId() {
+ return mValuesId;
+ }
+
+ public int getSelectedValue() {
+ if (getSetting() != null) {
+ IntSetting setting = (IntSetting) getSetting();
+ return setting.getValue();
+ } else {
+ return mDefaultValue;
+ }
+ }
+
+ /**
+ * 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 null if overwritten successfully otherwise; a newly created IntSetting.
+ */
+ public IntSetting setSelectedValue(int selection) {
+ if (getSetting() == null) {
+ IntSetting setting = new IntSetting(getKey(), getSection(), selection);
+ setSetting(setting);
+ return setting;
+ } else {
+ IntSetting setting = (IntSetting) getSetting();
+ setting.setValue(selection);
+ return null;
+ }
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_SINGLE_CHOICE;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java
new file mode 100644
index 0000000000..551b13f999
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java
@@ -0,0 +1,101 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.FloatSetting;
+import org.citra.citra_emu.features.settings.model.IntSetting;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.utils.Log;
+
+public final class SliderSetting extends SettingsItem {
+ private int mMin;
+ private int mMax;
+ private int mDefaultValue;
+
+ private String mUnits;
+
+ public SliderSetting(String key, String section, int titleId, int descriptionId,
+ int min, int max, String units, int defaultValue, Setting setting) {
+ super(key, section, setting, titleId, descriptionId);
+ mMin = min;
+ mMax = max;
+ mUnits = units;
+ mDefaultValue = defaultValue;
+ }
+
+ public int getMin() {
+ return mMin;
+ }
+
+ public int getMax() {
+ return mMax;
+ }
+
+ public int getDefaultValue() {
+ return mDefaultValue;
+ }
+
+ public int getSelectedValue() {
+ Setting setting = getSetting();
+
+ if (setting == null) {
+ return mDefaultValue;
+ }
+
+ if (setting instanceof IntSetting) {
+ IntSetting intSetting = (IntSetting) setting;
+ return intSetting.getValue();
+ } else if (setting instanceof FloatSetting) {
+ FloatSetting floatSetting = (FloatSetting) setting;
+ return Math.round(floatSetting.getValue());
+ } else {
+ Log.error("[SliderSetting] Error casting setting type.");
+ 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 null if overwritten successfully otherwise; a newly created IntSetting.
+ */
+ public IntSetting setSelectedValue(int selection) {
+ if (getSetting() == null) {
+ IntSetting setting = new IntSetting(getKey(), getSection(), selection);
+ setSetting(setting);
+ return setting;
+ } else {
+ IntSetting setting = (IntSetting) getSetting();
+ setting.setValue(selection);
+ return null;
+ }
+ }
+
+ /**
+ * 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 null if overwritten successfully otherwise; a newly created FloatSetting.
+ */
+ public FloatSetting setSelectedValue(float selection) {
+ if (getSetting() == null) {
+ FloatSetting setting = new FloatSetting(getKey(), getSection(), selection);
+ setSetting(setting);
+ return setting;
+ } else {
+ FloatSetting setting = (FloatSetting) getSetting();
+ setting.setValue(selection);
+ return null;
+ }
+ }
+
+ public String getUnits() {
+ return mUnits;
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_SLIDER;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java
new file mode 100644
index 0000000000..057145d9da
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java
@@ -0,0 +1,82 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+
+public class StringSingleChoiceSetting extends SettingsItem {
+ private String mDefaultValue;
+
+ private String[] mChoicesId;
+ private String[] mValuesId;
+
+ public StringSingleChoiceSetting(String key, String section, int titleId, int descriptionId,
+ String[] choicesId, String[] valuesId, String defaultValue, Setting setting) {
+ super(key, section, setting, titleId, descriptionId);
+ mValuesId = valuesId;
+ mChoicesId = choicesId;
+ mDefaultValue = defaultValue;
+ }
+
+ public String[] getChoicesId() {
+ return mChoicesId;
+ }
+
+ public String[] getValuesId() {
+ return mValuesId;
+ }
+
+ public String getValueAt(int index) {
+ if (mValuesId == null)
+ return null;
+
+ if (index >= 0 && index < mValuesId.length) {
+ return mValuesId[index];
+ }
+
+ return "";
+ }
+
+ public String getSelectedValue() {
+ if (getSetting() != null) {
+ StringSetting setting = (StringSetting) getSetting();
+ return setting.getValue();
+ } else {
+ return mDefaultValue;
+ }
+ }
+
+ public int getSelectValueIndex() {
+ String selectedValue = getSelectedValue();
+ for (int i = 0; i < mValuesId.length; i++) {
+ if (mValuesId[i].equals(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 null if overwritten successfully otherwise; a newly created IntSetting.
+ */
+ public StringSetting setSelectedValue(String selection) {
+ if (getSetting() == null) {
+ StringSetting setting = new StringSetting(getKey(), getSection(), selection);
+ setSetting(setting);
+ return setting;
+ } else {
+ StringSetting setting = (StringSetting) getSetting();
+ setting.setValue(selection);
+ return null;
+ }
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_STRING_SINGLE_CHOICE;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java
new file mode 100644
index 0000000000..9d44a923fd
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java
@@ -0,0 +1,21 @@
+package org.citra.citra_emu.features.settings.model.view;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+
+public final class SubmenuSetting extends SettingsItem {
+ private String mMenuKey;
+
+ public SubmenuSetting(String key, Setting setting, int titleId, int descriptionId, String menuKey) {
+ super(key, null, setting, titleId, descriptionId);
+ mMenuKey = menuKey;
+ }
+
+ public String getMenuKey() {
+ return mMenuKey;
+ }
+
+ @Override
+ public int getType() {
+ return TYPE_SUBMENU;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java
new file mode 100644
index 0000000000..23c3c4c9eb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java
@@ -0,0 +1,215 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.app.ProgressDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.fragment.app.FragmentTransaction;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.DirectoryStateReceiver;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+
+public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView {
+ private static final String ARG_MENU_TAG = "menu_tag";
+ private static final String ARG_GAME_ID = "game_id";
+ private static final String FRAGMENT_TAG = "settings";
+ private SettingsActivityPresenter mPresenter = new SettingsActivityPresenter(this);
+
+ private ProgressDialog dialog;
+
+ public static void launch(Context context, String menuTag, String gameId) {
+ Intent settings = new Intent(context, SettingsActivity.class);
+ settings.putExtra(ARG_MENU_TAG, menuTag);
+ settings.putExtra(ARG_GAME_ID, gameId);
+ context.startActivity(settings);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_settings);
+
+ Intent launcher = getIntent();
+ String gameID = launcher.getStringExtra(ARG_GAME_ID);
+ String menuTag = launcher.getStringExtra(ARG_MENU_TAG);
+
+ mPresenter.onCreate(savedInstanceState, menuTag, gameID);
+
+ // Show "Back" button in the action bar for navigation
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ }
+
+ @Override
+ public boolean onSupportNavigateUp() {
+ onBackPressed();
+
+ return true;
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_settings, menu);
+
+ return true;
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ // Critical: If super method is not called, rotations will be busted.
+ super.onSaveInstanceState(outState);
+ mPresenter.saveState(outState);
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ mPresenter.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
+ protected void onStop() {
+ super.onStop();
+
+ mPresenter.onStop(isFinishing());
+
+ // Update framebuffer layout when closing the settings
+ NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(),
+ getWindowManager().getDefaultDisplay().getRotation());
+ }
+
+ @Override
+ public void showSettingsFragment(String menuTag, boolean addToStack, String gameID) {
+ if (!addToStack && getFragment() != null) {
+ return;
+ }
+
+ FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
+
+ if (addToStack) {
+ if (areSystemAnimationsEnabled()) {
+ transaction.setCustomAnimations(
+ R.animator.settings_enter,
+ R.animator.settings_exit,
+ R.animator.settings_pop_enter,
+ R.animator.setttings_pop_exit);
+ }
+
+ transaction.addToBackStack(null);
+ }
+ transaction.replace(R.id.frame_content, SettingsFragment.newInstance(menuTag, gameID), FRAGMENT_TAG);
+
+ transaction.commit();
+ }
+
+ private boolean areSystemAnimationsEnabled() {
+ float duration = Settings.Global.getFloat(
+ getContentResolver(),
+ Settings.Global.ANIMATOR_DURATION_SCALE, 1);
+ float transition = Settings.Global.getFloat(
+ getContentResolver(),
+ Settings.Global.TRANSITION_ANIMATION_SCALE, 1);
+ return duration != 0 && transition != 0;
+ }
+
+ @Override
+ public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) {
+ LocalBroadcastManager.getInstance(this).registerReceiver(
+ receiver,
+ filter);
+ DirectoryInitialization.start(this);
+ }
+
+ @Override
+ public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) {
+ LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
+ }
+
+ @Override
+ public void showLoading() {
+ if (dialog == null) {
+ dialog = new ProgressDialog(this);
+ dialog.setMessage(getString(R.string.load_settings));
+ dialog.setIndeterminate(true);
+ }
+
+ dialog.show();
+ }
+
+ @Override
+ public void hideLoading() {
+ dialog.dismiss();
+ }
+
+ @Override
+ public void showPermissionNeededHint() {
+ Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)
+ .show();
+ }
+
+ @Override
+ public void showExternalStorageNotMountedHint() {
+ Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT)
+ .show();
+ }
+
+ @Override
+ public org.citra.citra_emu.features.settings.model.Settings getSettings() {
+ return mPresenter.getSettings();
+ }
+
+ @Override
+ public void setSettings(org.citra.citra_emu.features.settings.model.Settings settings) {
+ mPresenter.setSettings(settings);
+ }
+
+ @Override
+ public void onSettingsFileLoaded(org.citra.citra_emu.features.settings.model.Settings settings) {
+ SettingsFragmentView fragment = getFragment();
+
+ if (fragment != null) {
+ fragment.onSettingsFileLoaded(settings);
+ }
+ }
+
+ @Override
+ public void onSettingsFileNotFound() {
+ SettingsFragmentView fragment = getFragment();
+
+ if (fragment != null) {
+ fragment.loadDefaultSettings();
+ }
+ }
+
+ @Override
+ public void showToastMessage(String message, boolean is_long) {
+ Toast.makeText(this, message, is_long ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onSettingChanged() {
+ mPresenter.onSettingChanged();
+ }
+
+ private SettingsFragment getFragment() {
+ return (SettingsFragment) getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
new file mode 100644
index 0000000000..0d63873bb5
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java
@@ -0,0 +1,124 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.content.IntentFilter;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
+import org.citra.citra_emu.utils.DirectoryStateReceiver;
+import org.citra.citra_emu.utils.Log;
+import org.citra.citra_emu.utils.ThemeUtil;
+
+import java.io.File;
+
+public final class SettingsActivityPresenter {
+ private static final String KEY_SHOULD_SAVE = "should_save";
+
+ private SettingsActivityView mView;
+
+ private Settings mSettings = new Settings();
+
+ private boolean mShouldSave;
+
+ private DirectoryStateReceiver directoryStateReceiver;
+
+ private String menuTag;
+ private String gameId;
+
+ public SettingsActivityPresenter(SettingsActivityView view) {
+ mView = view;
+ }
+
+ public void onCreate(Bundle savedInstanceState, String menuTag, String gameId) {
+ if (savedInstanceState == null) {
+ this.menuTag = menuTag;
+ this.gameId = gameId;
+ } else {
+ mShouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE);
+ }
+ }
+
+ public void onStart() {
+ prepareCitraDirectoriesIfNeeded();
+ }
+
+ void loadSettingsUI() {
+ if (mSettings.isEmpty()) {
+ if (!TextUtils.isEmpty(gameId)) {
+ mSettings.loadSettings(gameId, mView);
+ } else {
+ mSettings.loadSettings(mView);
+ }
+ }
+
+ mView.showSettingsFragment(menuTag, false, gameId);
+ mView.onSettingsFileLoaded(mSettings);
+ }
+
+ private void prepareCitraDirectoriesIfNeeded() {
+ File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini");
+ if (!configFile.exists()) {
+ Log.error("Citra config file could not be found!");
+ }
+ if (DirectoryInitialization.areCitraDirectoriesReady()) {
+ loadSettingsUI();
+ } else {
+ mView.showLoading();
+ IntentFilter statusIntentFilter = new IntentFilter(
+ DirectoryInitialization.BROADCAST_ACTION);
+
+ directoryStateReceiver =
+ new DirectoryStateReceiver(directoryInitializationState ->
+ {
+ if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
+ mView.hideLoading();
+ loadSettingsUI();
+ } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
+ mView.showPermissionNeededHint();
+ mView.hideLoading();
+ } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
+ mView.showExternalStorageNotMountedHint();
+ mView.hideLoading();
+ }
+ });
+
+ mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter);
+ }
+ }
+
+ public void setSettings(Settings settings) {
+ mSettings = settings;
+ }
+
+ public Settings getSettings() {
+ return mSettings;
+ }
+
+ public void onStop(boolean finishing) {
+ if (directoryStateReceiver != null) {
+ mView.stopListeningToDirectoryInitializationService(directoryStateReceiver);
+ directoryStateReceiver = null;
+ }
+
+ if (mSettings != null && finishing && mShouldSave) {
+ Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...");
+ mSettings.saveSettings(mView);
+ }
+
+ ThemeUtil.applyTheme();
+
+ NativeLibrary.ReloadSettings();
+ }
+
+ public void onSettingChanged() {
+ mShouldSave = true;
+ }
+
+ public void saveState(Bundle outState) {
+ outState.putBoolean(KEY_SHOULD_SAVE, mShouldSave);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java
new file mode 100644
index 0000000000..0d26d48a71
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java
@@ -0,0 +1,103 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.content.IntentFilter;
+
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.utils.DirectoryStateReceiver;
+
+/**
+ * Abstraction for the Activity that manages SettingsFragments.
+ */
+public 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.
+ */
+ void showSettingsFragment(String menuTag, boolean addToStack, String gameId);
+
+ /**
+ * 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 possibly null HashMap of Settings.
+ */
+ Settings getSettings();
+
+ /**
+ * Used to provide the Activity with Settings HashMaps if a Fragment already
+ * has one; for example, if a rotation occurs, the Fragment will not be killed,
+ * but the Activity will, so the Activity needs to have its HashMaps resupplied.
+ *
+ * @param settings The ArrayList of all the Settings HashMaps.
+ */
+ void setSettings(Settings settings);
+
+ /**
+ * Called when an asynchronous load operation completes.
+ *
+ * @param settings The (possibly null) result of the ini load operation.
+ */
+ void onSettingsFileLoaded(Settings settings);
+
+ /**
+ * Called when an asynchronous load operation fails.
+ */
+ void 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.
+ */
+ void showToastMessage(String message, boolean is_long);
+
+ /**
+ * End the activity.
+ */
+ void 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.
+ */
+ void onSettingChanged();
+
+ /**
+ * Show loading dialog while loading the settings
+ */
+ void showLoading();
+
+ /**
+ * Hide the loading the dialog
+ */
+ void hideLoading();
+
+ /**
+ * Show a hint to the user that the app needs write to external storage access
+ */
+ void showPermissionNeededHint();
+
+ /**
+ * Show a hint to the user that the app needs the external storage to be mounted
+ */
+ void showExternalStorageNotMountedHint();
+
+ /**
+ * Start the DirectoryInitialization and listen for the result.
+ *
+ * @param receiver the broadcast receiver for the DirectoryInitialization
+ * @param filter the Intent broadcasts to be received.
+ */
+ void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter);
+
+ /**
+ * Stop listening to the DirectoryInitialization.
+ *
+ * @param receiver The broadcast receiver to unregister.
+ */
+ void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java
new file mode 100644
index 0000000000..bfd7c71a99
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java
@@ -0,0 +1,487 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.DatePicker;
+import android.widget.SeekBar;
+import android.widget.TextView;
+import android.widget.TimePicker;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.dialogs.MotionAlertDialog;
+import org.citra.citra_emu.features.settings.model.FloatSetting;
+import org.citra.citra_emu.features.settings.model.IntSetting;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
+import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
+import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
+import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SliderSetting;
+import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SubmenuSetting;
+import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder;
+import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder;
+import org.citra.citra_emu.ui.main.MainActivity;
+import org.citra.citra_emu.utils.Log;
+
+import java.util.ArrayList;
+
+public final class SettingsAdapter extends RecyclerView.Adapter
+ implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener {
+ private SettingsFragmentView mView;
+ private Context mContext;
+ private ArrayList mSettings;
+
+ private SettingsItem mClickedItem;
+ private int mClickedPosition;
+ private int mSeekbarProgress;
+
+ private AlertDialog mDialog;
+ private TextView mTextSliderValue;
+
+ public SettingsAdapter(SettingsFragmentView view, Context context) {
+ mView = view;
+ mContext = context;
+ mClickedPosition = -1;
+ }
+
+ @Override
+ public SettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view;
+ LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+
+ switch (viewType) {
+ case SettingsItem.TYPE_HEADER:
+ view = inflater.inflate(R.layout.list_item_settings_header, parent, false);
+ return new HeaderViewHolder(view, this);
+
+ case SettingsItem.TYPE_CHECKBOX:
+ view = inflater.inflate(R.layout.list_item_setting_checkbox, parent, false);
+ return new CheckBoxSettingViewHolder(view, this);
+
+ case SettingsItem.TYPE_SINGLE_CHOICE:
+ case SettingsItem.TYPE_STRING_SINGLE_CHOICE:
+ view = inflater.inflate(R.layout.list_item_setting, parent, false);
+ return new SingleChoiceViewHolder(view, this);
+
+ case SettingsItem.TYPE_SLIDER:
+ view = inflater.inflate(R.layout.list_item_setting, parent, false);
+ return new SliderViewHolder(view, this);
+
+ case SettingsItem.TYPE_SUBMENU:
+ view = inflater.inflate(R.layout.list_item_setting, parent, false);
+ return new SubmenuViewHolder(view, this);
+
+ case SettingsItem.TYPE_INPUT_BINDING:
+ view = inflater.inflate(R.layout.list_item_setting, parent, false);
+ return new InputBindingSettingViewHolder(view, this, mContext);
+
+ case SettingsItem.TYPE_DATETIME_SETTING:
+ view = inflater.inflate(R.layout.list_item_setting, parent, false);
+ return new DateTimeViewHolder(view, this);
+
+ case SettingsItem.TYPE_PREMIUM:
+ view = inflater.inflate(R.layout.premium_item_setting, parent, false);
+ return new PremiumViewHolder(view, this, mView);
+
+ default:
+ Log.error("[SettingsAdapter] Invalid view type: " + viewType);
+ return null;
+ }
+ }
+
+ @Override
+ public void onBindViewHolder(SettingViewHolder holder, int position) {
+ holder.bind(getItem(position));
+ }
+
+ private SettingsItem getItem(int position) {
+ return mSettings.get(position);
+ }
+
+ @Override
+ public int getItemCount() {
+ if (mSettings != null) {
+ return mSettings.size();
+ } else {
+ return 0;
+ }
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return getItem(position).getType();
+ }
+
+ public void setSettings(ArrayList settings) {
+ mSettings = settings;
+ notifyDataSetChanged();
+ }
+
+ public void onBooleanClick(CheckBoxSetting item, int position, boolean checked) {
+ IntSetting setting = item.setChecked(checked);
+ notifyItemChanged(position);
+
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+
+ mView.onSettingChanged();
+ }
+
+ public void onSingleChoiceClick(PremiumSingleChoiceSetting item) {
+ mClickedItem = item;
+
+ int value = getSelectionForSingleChoiceValue(item);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity());
+
+ builder.setTitle(item.getNameId());
+ builder.setSingleChoiceItems(item.getChoicesId(), value, this);
+
+ mDialog = builder.show();
+ }
+
+ public void onSingleChoiceClick(SingleChoiceSetting item) {
+ mClickedItem = item;
+
+ int value = getSelectionForSingleChoiceValue(item);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity());
+
+ builder.setTitle(item.getNameId());
+ builder.setSingleChoiceItems(item.getChoicesId(), value, this);
+
+ mDialog = builder.show();
+ }
+
+ public void onSingleChoiceClick(SingleChoiceSetting item, int position) {
+ mClickedPosition = position;
+
+ if (!item.isPremium() || MainActivity.isPremiumActive()) {
+ // Setting is either not Premium, or the user has Premium
+ onSingleChoiceClick(item);
+ return;
+ }
+
+ // User needs Premium, invoke the billing flow
+ MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item));
+ }
+
+ public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) {
+ mClickedPosition = position;
+
+ if (!item.isPremium() || MainActivity.isPremiumActive()) {
+ // Setting is either not Premium, or the user has Premium
+ onSingleChoiceClick(item);
+ return;
+ }
+
+ // User needs Premium, invoke the billing flow
+ MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item));
+ }
+
+ public void onStringSingleChoiceClick(StringSingleChoiceSetting item) {
+ mClickedItem = item;
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity());
+
+ builder.setTitle(item.getNameId());
+ builder.setSingleChoiceItems(item.getChoicesId(), item.getSelectValueIndex(), this);
+
+ mDialog = builder.show();
+ }
+
+ public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) {
+ mClickedPosition = position;
+
+ if (!item.isPremium() || MainActivity.isPremiumActive()) {
+ // Setting is either not Premium, or the user has Premium
+ onStringSingleChoiceClick(item);
+ return;
+ }
+
+ // User needs Premium, invoke the billing flow
+ MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item));
+ }
+
+ DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog();
+
+ public void onDateTimeClick(DateTimeSetting item, int position) {
+ mClickedItem = item;
+ mClickedPosition = position;
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity());
+
+ LayoutInflater inflater = LayoutInflater.from(mView.getActivity());
+ View view = inflater.inflate(R.layout.sysclock_datetime_picker, null);
+
+ DatePicker dp = view.findViewById(R.id.date_picker);
+ TimePicker tp = view.findViewById(R.id.time_picker);
+
+ //set date and time to substrings of settingValue; format = 2018-12-24 04:20:69 (alright maybe not that 69)
+ String settingValue = item.getValue();
+ dp.updateDate(Integer.parseInt(settingValue.substring(0, 4)), Integer.parseInt(settingValue.substring(5, 7)) - 1, Integer.parseInt(settingValue.substring(8, 10)));
+
+ tp.setIs24HourView(true);
+ tp.setHour(Integer.parseInt(settingValue.substring(11, 13)));
+ tp.setMinute(Integer.parseInt(settingValue.substring(14, 16)));
+
+ DialogInterface.OnClickListener ok = (dialog, which) -> {
+ //set it
+ int year = dp.getYear();
+ if (year < 2000) {
+ year = 2000;
+ }
+ String month = ("00" + (dp.getMonth() + 1)).substring(String.valueOf(dp.getMonth() + 1).length());
+ String day = ("00" + dp.getDayOfMonth()).substring(String.valueOf(dp.getDayOfMonth()).length());
+ String hr = ("00" + tp.getHour()).substring(String.valueOf(tp.getHour()).length());
+ String min = ("00" + tp.getMinute()).substring(String.valueOf(tp.getMinute()).length());
+ String datetime = year + "-" + month + "-" + day + " " + hr + ":" + min + ":01";
+
+ StringSetting setting = item.setSelectedValue(datetime);
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+
+ mView.onSettingChanged();
+
+ mClickedItem = null;
+ closeDialog();
+ };
+
+ builder.setView(view);
+ builder.setPositiveButton(android.R.string.ok, ok);
+ builder.setNegativeButton(android.R.string.cancel, defaultCancelListener);
+ mDialog = builder.show();
+ }
+
+ public void onSliderClick(SliderSetting item, int position) {
+ mClickedItem = item;
+ mClickedPosition = position;
+ mSeekbarProgress = item.getSelectedValue();
+ AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity());
+
+ LayoutInflater inflater = LayoutInflater.from(mView.getActivity());
+ View view = inflater.inflate(R.layout.dialog_seekbar, null);
+
+ SeekBar seekbar = view.findViewById(R.id.seekbar);
+
+ builder.setTitle(item.getNameId());
+ builder.setView(view);
+ builder.setPositiveButton(android.R.string.ok, this);
+ builder.setNegativeButton(android.R.string.cancel, defaultCancelListener);
+ builder.setNeutralButton(R.string.slider_default, (DialogInterface dialog, int which) -> {
+ seekbar.setProgress(item.getDefaultValue());
+ onClick(dialog, which);
+ });
+ mDialog = builder.show();
+
+ mTextSliderValue = view.findViewById(R.id.text_value);
+ mTextSliderValue.setText(String.valueOf(mSeekbarProgress));
+
+ TextView units = view.findViewById(R.id.text_units);
+ units.setText(item.getUnits());
+
+ seekbar.setMin(item.getMin());
+ seekbar.setMax(item.getMax());
+ seekbar.setProgress(mSeekbarProgress);
+
+ seekbar.setOnSeekBarChangeListener(this);
+ }
+
+ public void onSubmenuClick(SubmenuSetting item) {
+ mView.loadSubMenu(item.getMenuKey());
+ }
+
+ public void onInputBindingClick(final InputBindingSetting item, final int position) {
+ final MotionAlertDialog dialog = new MotionAlertDialog(mContext, item);
+ dialog.setTitle(R.string.input_binding);
+
+ int messageResId = R.string.input_binding_description;
+ if (item.IsAxisMappingSupported() && !item.IsTrigger()) {
+ // Use specialized message for axis left/right or up/down
+ if (item.IsHorizontalOrientation()) {
+ messageResId = R.string.input_binding_description_horizontal_axis;
+ } else {
+ messageResId = R.string.input_binding_description_vertical_axis;
+ }
+ }
+
+ dialog.setMessage(String.format(mContext.getString(messageResId), mContext.getString(item.getNameId())));
+ dialog.setButton(AlertDialog.BUTTON_NEGATIVE, mContext.getString(android.R.string.cancel), this);
+ dialog.setButton(AlertDialog.BUTTON_NEUTRAL, mContext.getString(R.string.clear), (dialogInterface, i) ->
+ item.removeOldMapping());
+ dialog.setOnDismissListener(dialog1 ->
+ {
+ StringSetting setting = new StringSetting(item.getKey(), item.getSection(), item.getValue());
+ notifyItemChanged(position);
+
+ mView.putSetting(setting);
+
+ mView.onSettingChanged();
+ });
+ dialog.setCanceledOnTouchOutside(false);
+ dialog.show();
+ }
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (mClickedItem instanceof SingleChoiceSetting) {
+ SingleChoiceSetting scSetting = (SingleChoiceSetting) mClickedItem;
+
+ int value = getValueForSingleChoiceSelection(scSetting, which);
+ if (scSetting.getSelectedValue() != value) {
+ mView.onSettingChanged();
+ }
+
+ // Get the backing Setting, which may be null (if for example it was missing from the file)
+ IntSetting setting = scSetting.setSelectedValue(value);
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+
+ closeDialog();
+ } else if (mClickedItem instanceof PremiumSingleChoiceSetting) {
+ PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem;
+ scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which));
+ closeDialog();
+ } else if (mClickedItem instanceof StringSingleChoiceSetting) {
+ StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem;
+ String value = scSetting.getValueAt(which);
+ if (!scSetting.getSelectedValue().equals(value))
+ mView.onSettingChanged();
+
+ StringSetting setting = scSetting.setSelectedValue(value);
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+
+ closeDialog();
+ } else if (mClickedItem instanceof SliderSetting) {
+ SliderSetting sliderSetting = (SliderSetting) mClickedItem;
+ if (sliderSetting.getSelectedValue() != mSeekbarProgress) {
+ mView.onSettingChanged();
+ }
+
+ if (sliderSetting.getSetting() instanceof FloatSetting) {
+ float value = (float) mSeekbarProgress;
+
+ FloatSetting setting = sliderSetting.setSelectedValue(value);
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+ } else {
+ IntSetting setting = sliderSetting.setSelectedValue(mSeekbarProgress);
+ if (setting != null) {
+ mView.putSetting(setting);
+ }
+ }
+
+ closeDialog();
+ }
+
+ mClickedItem = null;
+ mSeekbarProgress = -1;
+ }
+
+ public void closeDialog() {
+ if (mDialog != null) {
+ if (mClickedPosition != -1) {
+ notifyItemChanged(mClickedPosition);
+ mClickedPosition = -1;
+ }
+ mDialog.dismiss();
+ mDialog = null;
+ }
+ }
+
+ @Override
+ public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
+ mSeekbarProgress = progress;
+ mTextSliderValue.setText(String.valueOf(mSeekbarProgress));
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar seekBar) {
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar seekBar) {
+ }
+
+ private int getValueForSingleChoiceSelection(SingleChoiceSetting item, int which) {
+ int valuesId = item.getValuesId();
+
+ if (valuesId > 0) {
+ int[] valuesArray = mContext.getResources().getIntArray(valuesId);
+ return valuesArray[which];
+ } else {
+ return which;
+ }
+ }
+
+ private int getValueForSingleChoiceSelection(PremiumSingleChoiceSetting item, int which) {
+ int valuesId = item.getValuesId();
+
+ if (valuesId > 0) {
+ int[] valuesArray = mContext.getResources().getIntArray(valuesId);
+ return valuesArray[which];
+ } else {
+ return which;
+ }
+ }
+
+ private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) {
+ int value = item.getSelectedValue();
+ int valuesId = item.getValuesId();
+
+ if (valuesId > 0) {
+ int[] valuesArray = mContext.getResources().getIntArray(valuesId);
+ for (int index = 0; index < valuesArray.length; index++) {
+ int current = valuesArray[index];
+ if (current == value) {
+ return index;
+ }
+ }
+ } else {
+ return value;
+ }
+
+ return -1;
+ }
+
+ private int getSelectionForSingleChoiceValue(PremiumSingleChoiceSetting item) {
+ int value = item.getSelectedValue();
+ int valuesId = item.getValuesId();
+
+ if (valuesId > 0) {
+ int[] valuesArray = mContext.getResources().getIntArray(valuesId);
+ for (int index = 0; index < valuesArray.length; index++) {
+ int current = valuesArray[index];
+ if (current == value) {
+ return index;
+ }
+ }
+ } else {
+ return value;
+ }
+
+ return -1;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java
new file mode 100644
index 0000000000..5799dcb8dc
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java
@@ -0,0 +1,136 @@
+package org.citra.citra_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.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.ui.DividerItemDecoration;
+
+import java.util.ArrayList;
+
+public final class SettingsFragment extends Fragment implements SettingsFragmentView {
+ private static final String ARGUMENT_MENU_TAG = "menu_tag";
+ private static final String ARGUMENT_GAME_ID = "game_id";
+
+ private SettingsFragmentPresenter mPresenter = new SettingsFragmentPresenter(this);
+ private SettingsActivityView mActivity;
+
+ private SettingsAdapter mAdapter;
+
+ public static Fragment newInstance(String menuTag, String gameId) {
+ SettingsFragment fragment = new SettingsFragment();
+
+ Bundle arguments = new Bundle();
+ arguments.putString(ARGUMENT_MENU_TAG, menuTag);
+ arguments.putString(ARGUMENT_GAME_ID, gameId);
+
+ fragment.setArguments(arguments);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+
+ mActivity = (SettingsActivityView) context;
+ mPresenter.onAttach();
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setRetainInstance(true);
+ String menuTag = getArguments().getString(ARGUMENT_MENU_TAG);
+ String gameId = getArguments().getString(ARGUMENT_GAME_ID);
+
+ mAdapter = new SettingsAdapter(this, getActivity());
+
+ mPresenter.onCreate(menuTag, gameId);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_settings, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+ LinearLayoutManager manager = new LinearLayoutManager(getActivity());
+
+ RecyclerView recyclerView = view.findViewById(R.id.list_settings);
+
+ recyclerView.setAdapter(mAdapter);
+ recyclerView.setLayoutManager(manager);
+ recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null));
+
+ SettingsActivityView activity = (SettingsActivityView) getActivity();
+
+ mPresenter.onViewCreated(activity.getSettings());
+ }
+
+ @Override
+ public void onDetach() {
+ super.onDetach();
+ mActivity = null;
+
+ if (mAdapter != null) {
+ mAdapter.closeDialog();
+ }
+ }
+
+ @Override
+ public void onSettingsFileLoaded(Settings settings) {
+ mPresenter.setSettings(settings);
+ }
+
+ @Override
+ public void passSettingsToActivity(Settings settings) {
+ if (mActivity != null) {
+ mActivity.setSettings(settings);
+ }
+ }
+
+ @Override
+ public void showSettingsList(ArrayList settingsList) {
+ mAdapter.setSettings(settingsList);
+ }
+
+ @Override
+ public void loadDefaultSettings() {
+ mPresenter.loadDefaultSettings();
+ }
+
+ @Override
+ public void loadSubMenu(String menuKey) {
+ mActivity.showSettingsFragment(menuKey, true, getArguments().getString(ARGUMENT_GAME_ID));
+ }
+
+ @Override
+ public void showToastMessage(String message, boolean is_long) {
+ mActivity.showToastMessage(message, is_long);
+ }
+
+ @Override
+ public void putSetting(Setting setting) {
+ mPresenter.putSetting(setting);
+ }
+
+ @Override
+ public void onSettingChanged() {
+ mActivity.onSettingChanged();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
new file mode 100644
index 0000000000..31f3e68ebc
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java
@@ -0,0 +1,416 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.app.Activity;
+import android.content.Context;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraManager;
+import android.text.TextUtils;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.SettingSection;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
+import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
+import org.citra.citra_emu.features.settings.model.view.HeaderSetting;
+import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
+import org.citra.citra_emu.features.settings.model.view.PremiumHeader;
+import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SliderSetting;
+import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SubmenuSetting;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.utils.Log;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Objects;
+
+public final class SettingsFragmentPresenter {
+ private SettingsFragmentView mView;
+
+ private String mMenuTag;
+ private String mGameID;
+
+ private Settings mSettings;
+ private ArrayList mSettingsList;
+
+ public SettingsFragmentPresenter(SettingsFragmentView view) {
+ mView = view;
+ }
+
+ public void onCreate(String menuTag, String gameId) {
+ mGameID = gameId;
+ mMenuTag = menuTag;
+ }
+
+ public void onViewCreated(Settings settings) {
+ setSettings(settings);
+ }
+
+ /**
+ * If the screen is rotated, the Activity will forget the settings map. This fragment
+ * won't, though; so rather than have the Activity reload from disk, have the fragment pass
+ * the settings map back to the Activity.
+ */
+ public void onAttach() {
+ if (mSettings != null) {
+ mView.passSettingsToActivity(mSettings);
+ }
+ }
+
+ public void putSetting(Setting setting) {
+ mSettings.getSection(setting.getSection()).putSetting(setting);
+ }
+
+ private StringSetting asStringSetting(Setting setting) {
+ if (setting == null) {
+ return null;
+ }
+
+ StringSetting stringSetting = new StringSetting(setting.getKey(), setting.getSection(), setting.getValueAsString());
+ putSetting(stringSetting);
+ return stringSetting;
+ }
+
+ public void loadDefaultSettings() {
+ loadSettingsList();
+ }
+
+ public void setSettings(Settings settings) {
+ if (mSettingsList == null && settings != null) {
+ mSettings = settings;
+
+ loadSettingsList();
+ } else {
+ mView.getActivity().setTitle(R.string.preferences_settings);
+ mView.showSettingsList(mSettingsList);
+ }
+ }
+
+ private void loadSettingsList() {
+ if (!TextUtils.isEmpty(mGameID)) {
+ mView.getActivity().setTitle("Game Settings: " + mGameID);
+ }
+ ArrayList sl = new ArrayList<>();
+
+ if (mMenuTag == null) {
+ return;
+ }
+
+ switch (mMenuTag) {
+ case SettingsFile.FILE_NAME_CONFIG:
+ addConfigSettings(sl);
+ break;
+ case Settings.SECTION_PREMIUM:
+ addPremiumSettings(sl);
+ break;
+ case Settings.SECTION_CORE:
+ addGeneralSettings(sl);
+ break;
+ case Settings.SECTION_SYSTEM:
+ addSystemSettings(sl);
+ break;
+ case Settings.SECTION_CAMERA:
+ addCameraSettings(sl);
+ break;
+ case Settings.SECTION_CONTROLS:
+ addInputSettings(sl);
+ break;
+ case Settings.SECTION_RENDERER:
+ addGraphicsSettings(sl);
+ break;
+ case Settings.SECTION_AUDIO:
+ addAudioSettings(sl);
+ break;
+ case Settings.SECTION_DEBUG:
+ addDebugSettings(sl);
+ break;
+ default:
+ mView.showToastMessage("Unimplemented menu", false);
+ return;
+ }
+
+ mSettingsList = sl;
+ mView.showSettingsList(mSettingsList);
+ }
+
+ private void addConfigSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_settings);
+
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_controls, 0, Settings.SECTION_CONTROLS));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_graphics, 0, Settings.SECTION_RENDERER));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_audio, 0, Settings.SECTION_AUDIO));
+ sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG));
+ }
+
+ private void addPremiumSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_premium);
+
+ SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM);
+ Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN);
+
+ sl.add(new PremiumHeader());
+
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
+ sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView));
+ } else {
+ // Pre-Android 10 does not support System Default
+ sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView));
+ }
+
+ //Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME);
+ //sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, R.string.texture_filter_description, textureFilterNames, textureFilterNames, "none", textureFilterName));
+ }
+
+ private void addGeneralSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_general);
+
+ SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER);
+ Setting frameLimitEnable = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED);
+ Setting frameLimitValue = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT);
+
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED, Settings.SECTION_RENDERER, R.string.frame_limit_enable, R.string.frame_limit_enable_description, true, frameLimitEnable));
+ sl.add(new SliderSetting(SettingsFile.KEY_FRAME_LIMIT, Settings.SECTION_RENDERER, R.string.frame_limit_slider, R.string.frame_limit_slider_description, 1, 200, "%", 100, frameLimitValue));
+ }
+
+ private void addSystemSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_system);
+
+ SettingSection systemSection = mSettings.getSection(Settings.SECTION_SYSTEM);
+ Setting region = systemSection.getSetting(SettingsFile.KEY_REGION_VALUE);
+ Setting language = systemSection.getSetting(SettingsFile.KEY_LANGUAGE);
+ Setting systemClock = systemSection.getSetting(SettingsFile.KEY_INIT_CLOCK);
+ Setting dateTime = systemSection.getSetting(SettingsFile.KEY_INIT_TIME);
+
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_REGION_VALUE, Settings.SECTION_SYSTEM, R.string.emulated_region, 0, R.array.regionNames, R.array.regionValues, -1, region));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_LANGUAGE, Settings.SECTION_SYSTEM, R.string.emulated_language, 0, R.array.languageNames, R.array.languageValues, 1, language));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_INIT_CLOCK, Settings.SECTION_SYSTEM, R.string.init_clock, R.string.init_clock_description, R.array.systemClockNames, R.array.systemClockValues, 0, systemClock));
+ sl.add(new DateTimeSetting(SettingsFile.KEY_INIT_TIME, Settings.SECTION_SYSTEM, R.string.init_time, R.string.init_time_description, "2000-01-01 00:00:01", dateTime));
+ }
+
+ private void addCameraSettings(ArrayList sl) {
+ final Activity activity = mView.getActivity();
+ activity.setTitle(R.string.preferences_camera);
+
+ // Get the camera IDs
+ CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
+ ArrayList supportedCameraNameList = new ArrayList<>();
+ ArrayList supportedCameraIdList = new ArrayList<>();
+ if (cameraManager != null) {
+ try {
+ for (String id : cameraManager.getCameraIdList()) {
+ final CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id);
+ if (Objects.requireNonNull(characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)) == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) {
+ continue; // Legacy cameras cannot be used with the NDK
+ }
+
+ supportedCameraIdList.add(id);
+
+ final int facing = Objects.requireNonNull(characteristics.get(CameraCharacteristics.LENS_FACING));
+ int stringId = R.string.camera_facing_external;
+ switch (facing) {
+ case CameraCharacteristics.LENS_FACING_FRONT:
+ stringId = R.string.camera_facing_front;
+ break;
+ case CameraCharacteristics.LENS_FACING_BACK:
+ stringId = R.string.camera_facing_back;
+ break;
+ case CameraCharacteristics.LENS_FACING_EXTERNAL:
+ stringId = R.string.camera_facing_external;
+ break;
+ }
+ supportedCameraNameList.add(String.format("%1$s (%2$s)", id, activity.getString(stringId)));
+ }
+ } catch (CameraAccessException e) {
+ Log.error("Couldn't retrieve camera list");
+ e.printStackTrace();
+ }
+ }
+
+ // Create the names and values for display
+ ArrayList cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames)));
+ cameraDeviceNameList.addAll(supportedCameraNameList);
+ ArrayList cameraDeviceValueList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceValues)));
+ cameraDeviceValueList.addAll(supportedCameraIdList);
+
+ final String[] cameraDeviceNames = cameraDeviceNameList.toArray(new String[]{});
+ final String[] cameraDeviceValues = cameraDeviceValueList.toArray(new String[]{});
+
+ final boolean haveCameraDevices = !supportedCameraIdList.isEmpty();
+
+ String[] imageSourceNames = activity.getResources().getStringArray(R.array.cameraImageSourceNames);
+ String[] imageSourceValues = activity.getResources().getStringArray(R.array.cameraImageSourceValues);
+ if (!haveCameraDevices) {
+ // Remove the last entry (ndk / Device Camera)
+ imageSourceNames = Arrays.copyOfRange(imageSourceNames, 0, imageSourceNames.length - 1);
+ imageSourceValues = Arrays.copyOfRange(imageSourceValues, 0, imageSourceValues.length - 1);
+ }
+
+ final String defaultImageSource = haveCameraDevices ? "ndk" : "image";
+
+ SettingSection cameraSection = mSettings.getSection(Settings.SECTION_CAMERA);
+
+ Setting innerCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_NAME);
+ Setting innerCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG));
+ Setting innerCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_FLIP);
+ sl.add(new HeaderSetting(null, null, R.string.inner_camera, 0));
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, innerCameraImageSource));
+ if (haveCameraDevices)
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_front", innerCameraConfig));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, innerCameraFlip));
+
+ Setting outerLeftCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME);
+ Setting outerLeftCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG));
+ Setting outerLeftCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP);
+ sl.add(new HeaderSetting(null, null, R.string.outer_left_camera, 0));
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerLeftCameraImageSource));
+ if (haveCameraDevices)
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerLeftCameraConfig));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerLeftCameraFlip));
+
+ Setting outerRightCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME);
+ Setting outerRightCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG));
+ Setting outerRightCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP);
+ sl.add(new HeaderSetting(null, null, R.string.outer_right_camera, 0));
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerRightCameraImageSource));
+ if (haveCameraDevices)
+ sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerRightCameraConfig));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerRightCameraFlip));
+ }
+
+ private void addInputSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_controls);
+
+ SettingSection controlsSection = mSettings.getSection(Settings.SECTION_CONTROLS);
+ Setting buttonA = controlsSection.getSetting(SettingsFile.KEY_BUTTON_A);
+ Setting buttonB = controlsSection.getSetting(SettingsFile.KEY_BUTTON_B);
+ Setting buttonX = controlsSection.getSetting(SettingsFile.KEY_BUTTON_X);
+ Setting buttonY = controlsSection.getSetting(SettingsFile.KEY_BUTTON_Y);
+ Setting buttonSelect = controlsSection.getSetting(SettingsFile.KEY_BUTTON_SELECT);
+ Setting buttonStart = controlsSection.getSetting(SettingsFile.KEY_BUTTON_START);
+ Setting circlepadAxisVert = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL);
+ Setting circlepadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL);
+ Setting cstickAxisVert = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL);
+ Setting cstickAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL);
+ Setting dpadAxisVert = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL);
+ Setting dpadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL);
+ // Setting buttonUp = controlsSection.getSetting(SettingsFile.KEY_BUTTON_UP);
+ // Setting buttonDown = controlsSection.getSetting(SettingsFile.KEY_BUTTON_DOWN);
+ // Setting buttonLeft = controlsSection.getSetting(SettingsFile.KEY_BUTTON_LEFT);
+ // Setting buttonRight = controlsSection.getSetting(SettingsFile.KEY_BUTTON_RIGHT);
+ Setting buttonL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_L);
+ Setting buttonR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_R);
+ Setting buttonZL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZL);
+ Setting buttonZR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZR);
+
+ sl.add(new HeaderSetting(null, null, R.string.generic_buttons, 0));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_A, Settings.SECTION_CONTROLS, R.string.button_a, buttonA));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_B, Settings.SECTION_CONTROLS, R.string.button_b, buttonB));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_X, Settings.SECTION_CONTROLS, R.string.button_x, buttonX));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_Y, Settings.SECTION_CONTROLS, R.string.button_y, buttonY));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_SELECT, Settings.SECTION_CONTROLS, R.string.button_select, buttonSelect));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_START, Settings.SECTION_CONTROLS, R.string.button_start, buttonStart));
+
+ sl.add(new HeaderSetting(null, null, R.string.controller_circlepad, 0));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, circlepadAxisVert));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, circlepadAxisHoriz));
+
+ sl.add(new HeaderSetting(null, null, R.string.controller_c, 0));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, cstickAxisVert));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, cstickAxisHoriz));
+
+ sl.add(new HeaderSetting(null, null, R.string.controller_dpad, 0));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, dpadAxisVert));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, dpadAxisHoriz));
+
+ // TODO(bunnei): Figure out what to do with these. Configuring is functional, but removing for MVP because they are confusing.
+ // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_UP, Settings.SECTION_CONTROLS, R.string.generic_up, buttonUp));
+ // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_DOWN, Settings.SECTION_CONTROLS, R.string.generic_down, buttonDown));
+ // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_LEFT, Settings.SECTION_CONTROLS, R.string.generic_left, buttonLeft));
+ // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_RIGHT, Settings.SECTION_CONTROLS, R.string.generic_right, buttonRight));
+
+ sl.add(new HeaderSetting(null, null, R.string.controller_triggers, 0));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_L, Settings.SECTION_CONTROLS, R.string.button_l, buttonL));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_R, Settings.SECTION_CONTROLS, R.string.button_r, buttonR));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZL, Settings.SECTION_CONTROLS, R.string.button_zl, buttonZL));
+ sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZR, Settings.SECTION_CONTROLS, R.string.button_zr, buttonZR));
+ }
+
+ private void addGraphicsSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_graphics);
+
+ SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER);
+ Setting resolutionFactor = rendererSection.getSetting(SettingsFile.KEY_RESOLUTION_FACTOR);
+ Setting filterMode = rendererSection.getSetting(SettingsFile.KEY_FILTER_MODE);
+ Setting shadersAccurateMul = rendererSection.getSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL);
+ Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D);
+ Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D);
+ Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE);
+ SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT);
+ Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE);
+ Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT);
+ Setting cardboardYShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT);
+ SettingSection utilitySection = mSettings.getSection(Settings.SECTION_UTILITY);
+ Setting dumpTextures = utilitySection.getSetting(SettingsFile.KEY_DUMP_TEXTURES);
+ Setting customTextures = utilitySection.getSetting(SettingsFile.KEY_CUSTOM_TEXTURES);
+ //Setting preloadTextures = utilitySection.getSetting(SettingsFile.KEY_PRELOAD_TEXTURES);
+
+ sl.add(new HeaderSetting(null, null, R.string.renderer, 0));
+ sl.add(new SliderSetting(SettingsFile.KEY_RESOLUTION_FACTOR, Settings.SECTION_RENDERER, R.string.internal_resolution, R.string.internal_resolution_description, 1, 4, "x", 1, resolutionFactor));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache));
+
+ sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode));
+ sl.add(new SliderSetting(SettingsFile.KEY_FACTOR_3D, Settings.SECTION_RENDERER, R.string.factor3d, R.string.factor3d_description, 0, 100, "%", 0, factor3d));
+
+ sl.add(new HeaderSetting(null, null, R.string.cardboard_vr, 0));
+ sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE, Settings.SECTION_LAYOUT, R.string.cardboard_screen_size, R.string.cardboard_screen_size_description, 30, 100, "%", 85, cardboardScreenSize));
+ sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_x_shift, R.string.cardboard_x_shift_description, -100, 100, "%", 0, cardboardXShift));
+ sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_y_shift, R.string.cardboard_y_shift_description, -100, 100, "%", 0, cardboardYShift));
+
+ sl.add(new HeaderSetting(null, null, R.string.utility, 0));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_DUMP_TEXTURES, Settings.SECTION_UTILITY, R.string.dump_textures, R.string.dump_textures_description, false, dumpTextures));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_CUSTOM_TEXTURES, Settings.SECTION_UTILITY, R.string.custom_textures, R.string.custom_textures_description, false, customTextures));
+ //Disabled until custom texture implementation gets rewrite, current one overloads RAM and crashes Citra.
+ //sl.add(new CheckBoxSetting(SettingsFile.KEY_PRELOAD_TEXTURES, Settings.SECTION_UTILITY, R.string.preload_textures, R.string.preload_textures_description, false, preloadTextures));
+ }
+
+ private void addAudioSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_audio);
+
+ SettingSection audioSection = mSettings.getSection(Settings.SECTION_AUDIO);
+ Setting audioStretch = audioSection.getSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING);
+ Setting micInputType = audioSection.getSetting(SettingsFile.KEY_MIC_INPUT_TYPE);
+
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING, Settings.SECTION_AUDIO, R.string.audio_stretch, R.string.audio_stretch_description, true, audioStretch));
+ sl.add(new SingleChoiceSetting(SettingsFile.KEY_MIC_INPUT_TYPE, Settings.SECTION_AUDIO, R.string.audio_input_type, 0, R.array.audioInputTypeNames, R.array.audioInputTypeValues, 1, micInputType));
+ }
+
+ private void addDebugSettings(ArrayList sl) {
+ mView.getActivity().setTitle(R.string.preferences_debug);
+
+ SettingSection coreSection = mSettings.getSection(Settings.SECTION_CORE);
+ SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER);
+ Setting useCpuJit = coreSection.getSetting(SettingsFile.KEY_CPU_JIT);
+ Setting hardwareRenderer = rendererSection.getSetting(SettingsFile.KEY_HW_RENDERER);
+ Setting hardwareShader = rendererSection.getSetting(SettingsFile.KEY_HW_SHADER);
+ Setting vsyncEnable = rendererSection.getSetting(SettingsFile.KEY_USE_VSYNC);
+
+ sl.add(new HeaderSetting(null, null, R.string.debug_warning, 0));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_CPU_JIT, Settings.SECTION_CORE, R.string.cpu_jit, R.string.cpu_jit_description, true, useCpuJit, true, mView));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_RENDERER, Settings.SECTION_RENDERER, R.string.hw_renderer, R.string.hw_renderer_description, true, hardwareRenderer, true, mView));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_SHADER, Settings.SECTION_RENDERER, R.string.hw_shaders, R.string.hw_shaders_description, true, hardwareShader, true, mView));
+ sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_VSYNC, Settings.SECTION_RENDERER, R.string.vsync, R.string.vsync_description, true, vsyncEnable));
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java
new file mode 100644
index 0000000000..c36eb55a7c
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java
@@ -0,0 +1,78 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import androidx.fragment.app.FragmentActivity;
+
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+
+import java.util.ArrayList;
+
+/**
+ * Abstraction for a screen showing a list of settings. Instances of
+ * this type of view will each display a layer of the setting hierarchy.
+ */
+public interface SettingsFragmentView {
+ /**
+ * Called by the containing Activity to notify the Fragment that an
+ * asynchronous load operation completed.
+ *
+ * @param settings The (possibly null) result of the ini load operation.
+ */
+ void onSettingsFileLoaded(Settings settings);
+
+ /**
+ * Pass a settings HashMap to the containing activity, so that it can
+ * share the HashMap with other SettingsFragments; useful so that rotations
+ * do not require an additional load operation.
+ *
+ * @param settings An ArrayList containing all the settings HashMaps.
+ */
+ void passSettingsToActivity(Settings settings);
+
+ /**
+ * 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
+ */
+ void showSettingsList(ArrayList settingsList);
+
+ /**
+ * Called by the containing Activity when an asynchronous load operation fails.
+ * Instructs the Fragment to load the settings screen with defaults selected.
+ */
+ void loadDefaultSettings();
+
+ /**
+ * @return The Fragment's containing activity.
+ */
+ FragmentActivity getActivity();
+
+ /**
+ * 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.
+ */
+ void loadSubMenu(String menuKey);
+
+ /**
+ * 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.
+ */
+ void showToastMessage(String message, boolean is_long);
+
+ /**
+ * Have the fragment add a setting to the HashMap.
+ *
+ * @param setting The (possibly previously missing) new setting.
+ */
+ void putSetting(Setting setting);
+
+ /**
+ * Have the fragment tell the containing Activity that a setting was modified.
+ */
+ void onSettingChanged();
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java
new file mode 100644
index 0000000000..67bde57099
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java
@@ -0,0 +1,48 @@
+package org.citra.citra_emu.features.settings.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+
+/**
+ * FrameLayout subclass with few Properties added to simplify animations.
+ * Don't remove the methods appearing as unused, in order not to break the menu animations
+ */
+public final class SettingsFrameLayout extends FrameLayout {
+ private float mVisibleness = 1.0f;
+
+ public SettingsFrameLayout(Context context) {
+ super(context);
+ }
+
+ public SettingsFrameLayout(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public float getYFraction() {
+ return getY() / getHeight();
+ }
+
+ public void setYFraction(float yFraction) {
+ final int height = getHeight();
+ setY((height > 0) ? (yFraction * height) : -9999);
+ }
+
+ public float getVisibleness() {
+ return mVisibleness;
+ }
+
+ public void setVisibleness(float visibleness) {
+ setScaleX(visibleness);
+ setScaleY(visibleness);
+ setAlpha(visibleness);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java
new file mode 100644
index 0000000000..d914f7d0bc
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java
@@ -0,0 +1,54 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class CheckBoxSettingViewHolder extends SettingViewHolder {
+ private CheckBoxSetting mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ private CheckBox mCheckbox;
+
+ public CheckBoxSettingViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ mCheckbox = root.findViewById(R.id.checkbox);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mItem = (CheckBoxSetting) item;
+
+ mTextSettingName.setText(item.getNameId());
+
+ if (item.getDescriptionId() > 0) {
+ mTextSettingDescription.setText(item.getDescriptionId());
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ } else {
+ mTextSettingDescription.setText("");
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+
+ mCheckbox.setChecked(mItem.isChecked());
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ mCheckbox.toggle();
+
+ getAdapter().onBooleanClick(mItem, getAdapterPosition(), mCheckbox.isChecked());
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java
new file mode 100644
index 0000000000..09ea93010e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java
@@ -0,0 +1,47 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.DateTimeSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+import org.citra.citra_emu.utils.Log;
+
+public final class DateTimeViewHolder extends SettingViewHolder {
+ private DateTimeSetting mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ public DateTimeViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ Log.error("test " + mTextSettingName);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ Log.error("test " + mTextSettingDescription);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mItem = (DateTimeSetting) item;
+ mTextSettingName.setText(item.getNameId());
+
+ if (item.getDescriptionId() > 0) {
+ mTextSettingDescription.setText(item.getDescriptionId());
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ } else {
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ getAdapter().onDateTimeClick(mItem, getAdapterPosition());
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java
new file mode 100644
index 0000000000..baf80ed76d
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java
@@ -0,0 +1,32 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class HeaderViewHolder extends SettingViewHolder {
+ private TextView mHeaderName;
+
+ public HeaderViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ itemView.setOnClickListener(null);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mHeaderName = root.findViewById(R.id.text_header_name);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mHeaderName.setText(item.getNameId());
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ // no-op
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java
new file mode 100644
index 0000000000..7d95c250a1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java
@@ -0,0 +1,55 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class InputBindingSettingViewHolder extends SettingViewHolder {
+ private InputBindingSetting mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ private Context mContext;
+
+ public InputBindingSettingViewHolder(View itemView, SettingsAdapter adapter, Context context) {
+ super(itemView, adapter);
+
+ mContext = context;
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext);
+
+ mItem = (InputBindingSetting) item;
+
+ mTextSettingName.setText(item.getNameId());
+
+ String key = sharedPreferences.getString(mItem.getKey(), "");
+ if (key != null && !key.isEmpty()) {
+ mTextSettingDescription.setText(key);
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ } else {
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ getAdapter().onInputBindingClick(mItem, getAdapterPosition());
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java
new file mode 100644
index 0000000000..be0853ff0e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java
@@ -0,0 +1,57 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+import org.citra.citra_emu.features.settings.ui.SettingsFragmentView;
+import org.citra.citra_emu.ui.main.MainActivity;
+
+public final class PremiumViewHolder extends SettingViewHolder {
+ private TextView mHeaderName;
+ private TextView mTextDescription;
+ private SettingsFragmentView mView;
+
+ public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) {
+ super(itemView, adapter);
+ mView = view;
+ itemView.setOnClickListener(this);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mHeaderName = root.findViewById(R.id.text_setting_name);
+ mTextDescription = root.findViewById(R.id.text_setting_description);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ updateText();
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ if (MainActivity.isPremiumActive()) {
+ return;
+ }
+
+ // Invoke billing flow if Premium is not already active, then refresh the UI to indicate
+ // the purchase has completed.
+ MainActivity.invokePremiumBilling(() -> updateText());
+ }
+
+ /**
+ * Update the text shown to the user, based on whether Premium is active
+ */
+ private void updateText() {
+ if (MainActivity.isPremiumActive()) {
+ mHeaderName.setText(R.string.premium_settings_welcome);
+ mTextDescription.setText(R.string.premium_settings_welcome_description);
+ } else {
+ mHeaderName.setText(R.string.premium_settings_upsell);
+ mTextDescription.setText(R.string.premium_settings_upsell_description);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java
new file mode 100644
index 0000000000..2643ea1214
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java
@@ -0,0 +1,49 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public abstract class SettingViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
+ private SettingsAdapter mAdapter;
+
+ public SettingViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView);
+
+ mAdapter = adapter;
+
+ itemView.setOnClickListener(this);
+
+ findViews(itemView);
+ }
+
+ protected SettingsAdapter getAdapter() {
+ return mAdapter;
+ }
+
+ /**
+ * Gets handles to all this ViewHolder's child views using their XML-defined identifiers.
+ *
+ * @param root The newly inflated top-level view.
+ */
+ protected abstract void findViews(View root);
+
+ /**
+ * 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.
+ */
+ public abstract void bind(SettingsItem item);
+
+ /**
+ * 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.
+ */
+ public abstract void onClick(View clicked);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java
new file mode 100644
index 0000000000..a175af9f83
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java
@@ -0,0 +1,76 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.content.res.Resources;
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting;
+import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class SingleChoiceViewHolder extends SettingViewHolder {
+ private SettingsItem mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ public SingleChoiceViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mItem = item;
+
+ mTextSettingName.setText(item.getNameId());
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ if (item.getDescriptionId() > 0) {
+ mTextSettingDescription.setText(item.getDescriptionId());
+ } else if (item instanceof SingleChoiceSetting) {
+ SingleChoiceSetting setting = (SingleChoiceSetting) item;
+ int selected = setting.getSelectedValue();
+ Resources resMgr = mTextSettingDescription.getContext().getResources();
+ String[] choices = resMgr.getStringArray(setting.getChoicesId());
+ int[] values = resMgr.getIntArray(setting.getValuesId());
+ for (int i = 0; i < values.length; ++i) {
+ if (values[i] == selected) {
+ mTextSettingDescription.setText(choices[i]);
+ }
+ }
+ } else if (item instanceof PremiumSingleChoiceSetting) {
+ PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item;
+ int selected = setting.getSelectedValue();
+ Resources resMgr = mTextSettingDescription.getContext().getResources();
+ String[] choices = resMgr.getStringArray(setting.getChoicesId());
+ int[] values = resMgr.getIntArray(setting.getValuesId());
+ for (int i = 0; i < values.length; ++i) {
+ if (values[i] == selected) {
+ mTextSettingDescription.setText(choices[i]);
+ }
+ }
+ } else {
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ int position = getAdapterPosition();
+ if (mItem instanceof SingleChoiceSetting) {
+ getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position);
+ } else if (mItem instanceof PremiumSingleChoiceSetting) {
+ getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position);
+ } else if (mItem instanceof StringSingleChoiceSetting) {
+ getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java
new file mode 100644
index 0000000000..3dd048a296
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.model.view.SliderSetting;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class SliderViewHolder extends SettingViewHolder {
+ private SliderSetting mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ public SliderViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mItem = (SliderSetting) item;
+
+ mTextSettingName.setText(item.getNameId());
+
+ if (item.getDescriptionId() > 0) {
+ mTextSettingDescription.setText(item.getDescriptionId());
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ } else {
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ getAdapter().onSliderClick(mItem, getAdapterPosition());
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java
new file mode 100644
index 0000000000..cb8c3e92a7
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.features.settings.ui.viewholder;
+
+import android.view.View;
+import android.widget.TextView;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.view.SettingsItem;
+import org.citra.citra_emu.features.settings.model.view.SubmenuSetting;
+import org.citra.citra_emu.features.settings.ui.SettingsAdapter;
+
+public final class SubmenuViewHolder extends SettingViewHolder {
+ private SubmenuSetting mItem;
+
+ private TextView mTextSettingName;
+ private TextView mTextSettingDescription;
+
+ public SubmenuViewHolder(View itemView, SettingsAdapter adapter) {
+ super(itemView, adapter);
+ }
+
+ @Override
+ protected void findViews(View root) {
+ mTextSettingName = root.findViewById(R.id.text_setting_name);
+ mTextSettingDescription = root.findViewById(R.id.text_setting_description);
+ }
+
+ @Override
+ public void bind(SettingsItem item) {
+ mItem = (SubmenuSetting) item;
+
+ mTextSettingName.setText(item.getNameId());
+
+ if (item.getDescriptionId() > 0) {
+ mTextSettingDescription.setText(item.getDescriptionId());
+ mTextSettingDescription.setVisibility(View.VISIBLE);
+ } else {
+ mTextSettingDescription.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void onClick(View clicked) {
+ getAdapter().onSubmenuClick(mItem);
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
new file mode 100644
index 0000000000..8ae6b70d7f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java
@@ -0,0 +1,341 @@
+package org.citra.citra_emu.features.settings.utils;
+
+import androidx.annotation.NonNull;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.FloatSetting;
+import org.citra.citra_emu.features.settings.model.IntSetting;
+import org.citra.citra_emu.features.settings.model.Setting;
+import org.citra.citra_emu.features.settings.model.SettingSection;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.model.StringSetting;
+import org.citra.citra_emu.features.settings.ui.SettingsActivityView;
+import org.citra.citra_emu.utils.BiMap;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.Log;
+import org.ini4j.Wini;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * Contains static methods for interacting with .ini files in which settings are stored.
+ */
+public final class SettingsFile {
+ public static final String FILE_NAME_CONFIG = "config";
+
+ public static final String KEY_CPU_JIT = "use_cpu_jit";
+
+ public static final String KEY_DESIGN = "design";
+
+ public static final String KEY_PREMIUM = "premium";
+
+ public static final String KEY_HW_RENDERER = "use_hw_renderer";
+ public static final String KEY_HW_SHADER = "use_hw_shader";
+ public static final String KEY_SHADERS_ACCURATE_MUL = "shaders_accurate_mul";
+ public static final String KEY_USE_SHADER_JIT = "use_shader_jit";
+ public static final String KEY_USE_DISK_SHADER_CACHE = "use_disk_shader_cache";
+ public static final String KEY_USE_VSYNC = "use_vsync_new";
+ public static final String KEY_RESOLUTION_FACTOR = "resolution_factor";
+ public static final String KEY_FRAME_LIMIT_ENABLED = "use_frame_limit";
+ public static final String KEY_FRAME_LIMIT = "frame_limit";
+ public static final String KEY_BACKGROUND_RED = "bg_red";
+ public static final String KEY_BACKGROUND_BLUE = "bg_blue";
+ public static final String KEY_BACKGROUND_GREEN = "bg_green";
+ public static final String KEY_RENDER_3D = "render_3d";
+ public static final String KEY_FACTOR_3D = "factor_3d";
+ public static final String KEY_PP_SHADER_NAME = "pp_shader_name";
+ public static final String KEY_FILTER_MODE = "filter_mode";
+ public static final String KEY_TEXTURE_FILTER_NAME = "texture_filter_name";
+ public static final String KEY_USE_ASYNCHRONOUS_GPU_EMULATION = "use_asynchronous_gpu_emulation";
+
+ public static final String KEY_LAYOUT_OPTION = "layout_option";
+ public static final String KEY_SWAP_SCREEN = "swap_screen";
+ public static final String KEY_CARDBOARD_SCREEN_SIZE = "cardboard_screen_size";
+ public static final String KEY_CARDBOARD_X_SHIFT = "cardboard_x_shift";
+ public static final String KEY_CARDBOARD_Y_SHIFT = "cardboard_y_shift";
+
+ public static final String KEY_DUMP_TEXTURES = "dump_textures";
+ public static final String KEY_CUSTOM_TEXTURES = "custom_textures";
+ public static final String KEY_PRELOAD_TEXTURES = "preload_textures";
+
+ public static final String KEY_AUDIO_OUTPUT_ENGINE = "output_engine";
+ public static final String KEY_ENABLE_AUDIO_STRETCHING = "enable_audio_stretching";
+ public static final String KEY_VOLUME = "volume";
+ public static final String KEY_MIC_INPUT_TYPE = "mic_input_type";
+
+ public static final String KEY_USE_VIRTUAL_SD = "use_virtual_sd";
+
+ public static final String KEY_IS_NEW_3DS = "is_new_3ds";
+ public static final String KEY_REGION_VALUE = "region_value";
+ public static final String KEY_LANGUAGE = "language";
+
+ public static final String KEY_INIT_CLOCK = "init_clock";
+ public static final String KEY_INIT_TIME = "init_time";
+
+ public static final String KEY_BUTTON_A = "button_a";
+ public static final String KEY_BUTTON_B = "button_b";
+ public static final String KEY_BUTTON_X = "button_x";
+ public static final String KEY_BUTTON_Y = "button_y";
+ public static final String KEY_BUTTON_SELECT = "button_select";
+ public static final String KEY_BUTTON_START = "button_start";
+ public static final String KEY_BUTTON_UP = "button_up";
+ public static final String KEY_BUTTON_DOWN = "button_down";
+ public static final String KEY_BUTTON_LEFT = "button_left";
+ public static final String KEY_BUTTON_RIGHT = "button_right";
+ public static final String KEY_BUTTON_L = "button_l";
+ public static final String KEY_BUTTON_R = "button_r";
+ public static final String KEY_BUTTON_ZL = "button_zl";
+ public static final String KEY_BUTTON_ZR = "button_zr";
+ public static final String KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical";
+ public static final String KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal";
+ public static final String KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical";
+ public static final String KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal";
+ public static final String KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical";
+ public static final String KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal";
+ public static final String KEY_CIRCLEPAD_UP = "circlepad_up";
+ public static final String KEY_CIRCLEPAD_DOWN = "circlepad_down";
+ public static final String KEY_CIRCLEPAD_LEFT = "circlepad_left";
+ public static final String KEY_CIRCLEPAD_RIGHT = "circlepad_right";
+ public static final String KEY_CSTICK_UP = "cstick_up";
+ public static final String KEY_CSTICK_DOWN = "cstick_down";
+ public static final String KEY_CSTICK_LEFT = "cstick_left";
+ public static final String KEY_CSTICK_RIGHT = "cstick_right";
+
+ public static final String KEY_CAMERA_OUTER_RIGHT_NAME = "camera_outer_right_name";
+ public static final String KEY_CAMERA_OUTER_RIGHT_CONFIG = "camera_outer_right_config";
+ public static final String KEY_CAMERA_OUTER_RIGHT_FLIP = "camera_outer_right_flip";
+ public static final String KEY_CAMERA_OUTER_LEFT_NAME = "camera_outer_left_name";
+ public static final String KEY_CAMERA_OUTER_LEFT_CONFIG = "camera_outer_left_config";
+ public static final String KEY_CAMERA_OUTER_LEFT_FLIP = "camera_outer_left_flip";
+ public static final String KEY_CAMERA_INNER_NAME = "camera_inner_name";
+ public static final String KEY_CAMERA_INNER_CONFIG = "camera_inner_config";
+ public static final String KEY_CAMERA_INNER_FLIP = "camera_inner_flip";
+
+ public static final String KEY_LOG_FILTER = "log_filter";
+
+ private static BiMap sectionsMap = new BiMap<>();
+
+ static {
+ //TODO: Add members to sectionsMap when game-specific settings are added
+ }
+
+
+ private SettingsFile() {
+ }
+
+ /**
+ * 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.
+ */
+ static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) {
+ HashMap sections = new Settings.SettingsSectionMap();
+
+ BufferedReader reader = null;
+
+ try {
+ reader = new BufferedReader(new FileReader(ini));
+
+ SettingSection current = null;
+ for (String line; (line = reader.readLine()) != null; ) {
+ if (line.startsWith("[") && line.endsWith("]")) {
+ current = sectionFromLine(line, isCustomGame);
+ sections.put(current.getName(), current);
+ } else if ((current != null)) {
+ Setting setting = settingFromLine(current, line);
+ if (setting != null) {
+ current.putSetting(setting);
+ }
+ }
+ }
+ } catch (FileNotFoundException e) {
+ Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage());
+ if (view != null)
+ view.onSettingsFileNotFound();
+ } catch (IOException e) {
+ Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage());
+ if (view != null)
+ view.onSettingsFileNotFound();
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close();
+ } catch (IOException e) {
+ Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage());
+ }
+ }
+ }
+
+ return sections;
+ }
+
+ public static HashMap readFile(final String fileName, SettingsActivityView view) {
+ return readFile(getSettingsFile(fileName), false, view);
+ }
+
+ /**
+ * 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.
+ */
+ public static HashMap readCustomGameSettings(final String gameId, SettingsActivityView view) {
+ 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.
+ */
+ public static void saveFile(final String fileName, TreeMap sections,
+ SettingsActivityView view) {
+ File ini = getSettingsFile(fileName);
+
+ try {
+ Wini writer = new Wini(ini);
+
+ Set keySet = sections.keySet();
+ for (String key : keySet) {
+ SettingSection section = sections.get(key);
+ writeSection(writer, section);
+ }
+ writer.store();
+ } catch (IOException e) {
+ Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage());
+ view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false);
+ }
+ }
+
+
+ public static void saveCustomGameSettings(final String gameId, final HashMap sections) {
+ Set sortedSections = new TreeSet<>(sections.keySet());
+
+ for (String sectionKey : sortedSections) {
+ SettingSection section = sections.get(sectionKey);
+
+ HashMap settings = section.getSettings();
+ Set sortedKeySet = new TreeSet<>(settings.keySet());
+
+ for (String settingKey : sortedKeySet) {
+ Setting setting = settings.get(settingKey);
+ NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString());
+ }
+ }
+ }
+
+ private static String mapSectionNameFromIni(String generalSectionName) {
+ if (sectionsMap.getForward(generalSectionName) != null) {
+ return sectionsMap.getForward(generalSectionName);
+ }
+
+ return generalSectionName;
+ }
+
+ private static String mapSectionNameToIni(String generalSectionName) {
+ if (sectionsMap.getBackward(generalSectionName) != null) {
+ return sectionsMap.getBackward(generalSectionName);
+ }
+
+ return generalSectionName;
+ }
+
+ @NonNull
+ private static File getSettingsFile(String fileName) {
+ return new File(
+ DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini");
+ }
+
+ private static File getCustomGameSettingsFile(String gameId) {
+ return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini");
+ }
+
+ private static SettingSection sectionFromLine(String line, boolean isCustomGame) {
+ String sectionName = line.substring(1, line.length() - 1);
+ if (isCustomGame) {
+ sectionName = mapSectionNameToIni(sectionName);
+ }
+ return new SettingSection(sectionName);
+ }
+
+ /**
+ * For a line of text, determines what type of data is being represented, and returns
+ * a Setting object containing this data.
+ *
+ * @param current The section currently being parsed by the consuming method.
+ * @param line The line of text being parsed.
+ * @return A typed Setting containing the key/value contained in the line.
+ */
+ private static Setting settingFromLine(SettingSection current, String line) {
+ String[] splitLine = line.split("=");
+
+ if (splitLine.length != 2) {
+ Log.warning("Skipping invalid config line \"" + line + "\"");
+ return null;
+ }
+
+ String key = splitLine[0].trim();
+ String value = splitLine[1].trim();
+
+ if (value.isEmpty()) {
+ Log.warning("Skipping null value in config line \"" + line + "\"");
+ return null;
+ }
+
+ try {
+ int valueAsInt = Integer.parseInt(value);
+
+ return new IntSetting(key, current.getName(), valueAsInt);
+ } catch (NumberFormatException ex) {
+ }
+
+ try {
+ float valueAsFloat = Float.parseFloat(value);
+
+ return new FloatSetting(key, current.getName(), valueAsFloat);
+ } catch (NumberFormatException ex) {
+ }
+
+ return new StringSetting(key, current.getName(), value);
+ }
+
+ /**
+ * 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 static void writeSection(Wini parser, SettingSection section) {
+ // Write the section header.
+ String header = section.getName();
+
+ // Write this section's values.
+ HashMap settings = section.getSettings();
+ Set keySet = settings.keySet();
+
+ for (String key : keySet) {
+ Setting setting = settings.get(key);
+ parser.put(header, setting.getKey(), setting.getValueAsString());
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java
new file mode 100644
index 0000000000..c18ecd4c3d
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java
@@ -0,0 +1,120 @@
+package org.citra.citra_emu.fragments;
+
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.FileProvider;
+
+import com.nononsenseapps.filepicker.FilePickerFragment;
+
+import org.citra.citra_emu.R;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class CustomFilePickerFragment extends FilePickerFragment {
+ private static String ALL_FILES = "*";
+ private int mTitle;
+ private static List extensions = Collections.singletonList(ALL_FILES);
+
+ @NonNull
+ @Override
+ public Uri toUri(@NonNull final File file) {
+ return FileProvider
+ .getUriForFile(getContext(),
+ getContext().getApplicationContext().getPackageName() + ".filesprovider",
+ file);
+ }
+
+ @Override
+ public void onActivityCreated(Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+
+ if (mode == MODE_DIR) {
+ TextView ok = getActivity().findViewById(R.id.nnf_button_ok);
+ ok.setText(R.string.select_dir);
+
+ TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel);
+ cancel.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ protected View inflateRootView(LayoutInflater inflater, ViewGroup container) {
+ View view = super.inflateRootView(inflater, container);
+ if (mTitle != 0) {
+ Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar);
+ ViewGroup parent = (ViewGroup) toolbar.getParent();
+ int index = parent.indexOfChild(toolbar);
+ View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false);
+ TextView title = newToolbar.findViewById(R.id.filepicker_title);
+ title.setText(mTitle);
+ parent.removeView(toolbar);
+ parent.addView(newToolbar, index);
+ }
+ return view;
+ }
+
+ public void setTitle(int title) {
+ mTitle = title;
+ }
+
+ public void setAllowedExtensions(String allowedExtensions) {
+ if (allowedExtensions == null)
+ return;
+
+ extensions = Arrays.asList(allowedExtensions.split(","));
+ }
+
+ @Override
+ protected boolean isItemVisible(@NonNull final File file) {
+ // Some users jump to the conclusion that Dolphin isn't able to detect their
+ // files if the files don't show up in the file picker when mode == MODE_DIR.
+ // To avoid this, show files even when the user needs to select a directory.
+ return (showHiddenItems || !file.isHidden()) &&
+ (file.isDirectory() || extensions.contains(ALL_FILES) ||
+ extensions.contains(fileExtension(file.getName()).toLowerCase()));
+ }
+
+ @Override
+ public boolean isCheckable(@NonNull final File file) {
+ // We need to make a small correction to the isCheckable logic due to
+ // overriding isItemVisible to show files when mode == MODE_DIR.
+ // AbstractFilePickerFragment always treats files as checkable when
+ // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR.
+ return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile());
+ }
+
+ @Override
+ public void goUp() {
+ if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) {
+ goToDir(new File("/storage/"));
+ return;
+ }
+ if (mCurrentPath.equals(new File("/storage/"))){
+ return;
+ }
+ super.goUp();
+ }
+
+ @Override
+ public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) {
+ if(viewHolder.file.equals(new File("/storage/emulated/")))
+ viewHolder.file = new File("/storage/emulated/0/");
+ super.onClickDir(view, viewHolder);
+ }
+
+ private static String fileExtension(@NonNull String filename) {
+ int i = filename.lastIndexOf('.');
+ return i < 0 ? "" : filename.substring(i + 1);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java
new file mode 100644
index 0000000000..cdb40d6f85
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java
@@ -0,0 +1,380 @@
+package org.citra.citra_emu.fragments;
+
+import android.content.Context;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.preference.PreferenceManager;
+import android.view.Choreographer;
+import android.view.LayoutInflater;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.overlay.InputOverlay;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
+import org.citra.citra_emu.utils.DirectoryStateReceiver;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+import org.citra.citra_emu.utils.Log;
+
+public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback, Choreographer.FrameCallback {
+ private static final String KEY_GAMEPATH = "gamepath";
+
+ private static final Handler perfStatsUpdateHandler = new Handler();
+
+ private SharedPreferences mPreferences;
+
+ private InputOverlay mInputOverlay;
+
+ private EmulationState mEmulationState;
+
+ private DirectoryStateReceiver directoryStateReceiver;
+
+ private EmulationActivity activity;
+
+ private TextView mPerfStats;
+
+ private Runnable perfStatsUpdater;
+
+ public static EmulationFragment newInstance(String gamePath) {
+ Bundle args = new Bundle();
+ args.putString(KEY_GAMEPATH, gamePath);
+
+ EmulationFragment fragment = new EmulationFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+
+ if (context instanceof EmulationActivity) {
+ activity = (EmulationActivity) context;
+ NativeLibrary.setEmulationActivity((EmulationActivity) context);
+ } else {
+ throw new IllegalStateException("EmulationFragment must have EmulationActivity parent");
+ }
+ }
+
+ /**
+ * Initialize anything that doesn't depend on the layout / views in here.
+ */
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // So this fragment doesn't restart on configuration changes; i.e. rotation.
+ setRetainInstance(true);
+
+ mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity());
+
+ String gamePath = getArguments().getString(KEY_GAMEPATH);
+ mEmulationState = new EmulationState(gamePath);
+ }
+
+ /**
+ * Initialize the UI and start emulation in here.
+ */
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View contents = inflater.inflate(R.layout.fragment_emulation, container, false);
+
+ SurfaceView surfaceView = contents.findViewById(R.id.surface_emulation);
+ surfaceView.getHolder().addCallback(this);
+
+ mInputOverlay = contents.findViewById(R.id.surface_input_overlay);
+ mPerfStats = contents.findViewById(R.id.show_fps_text);
+ mPerfStats.setTextColor(Color.YELLOW);
+
+ Button doneButton = contents.findViewById(R.id.done_control_config);
+ if (doneButton != null) {
+ doneButton.setOnClickListener(v -> stopConfiguringControls());
+ }
+
+ // Show/hide the "Show FPS" overlay
+ updateShowFpsOverlay();
+
+ // The new Surface created here will get passed to the native code via onSurfaceChanged.
+ return contents;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ Choreographer.getInstance().postFrameCallback(this);
+ if (DirectoryInitialization.areCitraDirectoriesReady()) {
+ mEmulationState.run(activity.isActivityRecreated());
+ } else {
+ setupCitraDirectoriesThenStartEmulation();
+ }
+ }
+
+ @Override
+ public void onPause() {
+ if (directoryStateReceiver != null) {
+ LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver);
+ directoryStateReceiver = null;
+ }
+
+ if (mEmulationState.isRunning()) {
+ mEmulationState.pause();
+ }
+
+ Choreographer.getInstance().removeFrameCallback(this);
+ super.onPause();
+ }
+
+ @Override
+ public void onDetach() {
+ NativeLibrary.clearEmulationActivity();
+ super.onDetach();
+ }
+
+ private void setupCitraDirectoriesThenStartEmulation() {
+ IntentFilter statusIntentFilter = new IntentFilter(
+ DirectoryInitialization.BROADCAST_ACTION);
+
+ directoryStateReceiver =
+ new DirectoryStateReceiver(directoryInitializationState ->
+ {
+ if (directoryInitializationState ==
+ DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
+ mEmulationState.run(activity.isActivityRecreated());
+ } else if (directoryInitializationState ==
+ DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) {
+ Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT)
+ .show();
+ } else if (directoryInitializationState ==
+ DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) {
+ Toast.makeText(getContext(), R.string.external_storage_not_mounted,
+ Toast.LENGTH_SHORT)
+ .show();
+ }
+ });
+
+ // Registers the DirectoryStateReceiver and its intent filters
+ LocalBroadcastManager.getInstance(getActivity()).registerReceiver(
+ directoryStateReceiver,
+ statusIntentFilter);
+ DirectoryInitialization.start(getActivity());
+ }
+
+ public void refreshInputOverlay() {
+ mInputOverlay.refreshControls();
+ }
+
+ public void resetInputOverlay() {
+ // Reset button scale
+ SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putInt("controlScale", 50);
+ editor.apply();
+
+ mInputOverlay.resetButtonPlacement();
+ }
+
+ public void updateShowFpsOverlay() {
+ if (true) {
+ final int SYSTEM_FPS = 0;
+ final int FPS = 1;
+ final int FRAMETIME = 2;
+ final int SPEED = 3;
+
+ perfStatsUpdater = () ->
+ {
+ final double[] perfStats = NativeLibrary.GetPerfStats();
+ if (perfStats[FPS] > 0) {
+ mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS]),
+ (int) (perfStats[SPEED] * 100.0)));
+ }
+
+ perfStatsUpdateHandler.postDelayed(perfStatsUpdater, 3000);
+ };
+ perfStatsUpdateHandler.post(perfStatsUpdater);
+
+ mPerfStats.setVisibility(View.VISIBLE);
+ } else {
+ if (perfStatsUpdater != null) {
+ perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater);
+ }
+
+ mPerfStats.setVisibility(View.GONE);
+ }
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ // We purposely don't do anything here.
+ // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation.
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+ Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height);
+ mEmulationState.newSurface(holder.getSurface());
+ }
+
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ mEmulationState.clearSurface();
+ }
+
+ @Override
+ public void doFrame(long frameTimeNanos) {
+ Choreographer.getInstance().postFrameCallback(this);
+ NativeLibrary.DoFrame();
+ }
+
+ public void stopEmulation() {
+ mEmulationState.stop();
+ }
+
+ public void startConfiguringControls() {
+ getView().findViewById(R.id.done_control_config).setVisibility(View.VISIBLE);
+ mInputOverlay.setIsInEditMode(true);
+ }
+
+ public void stopConfiguringControls() {
+ getView().findViewById(R.id.done_control_config).setVisibility(View.GONE);
+ mInputOverlay.setIsInEditMode(false);
+ }
+
+ public boolean isConfiguringControls() {
+ return mInputOverlay.isInEditMode();
+ }
+
+ private static class EmulationState {
+ private final String mGamePath;
+ private State state;
+ private Surface mSurface;
+ private boolean mRunWhenSurfaceIsValid;
+
+ EmulationState(String gamePath) {
+ mGamePath = gamePath;
+ // Starting state is stopped.
+ state = State.STOPPED;
+ }
+
+ public synchronized boolean isStopped() {
+ return state == State.STOPPED;
+ }
+
+ // Getters for the current state
+
+ public synchronized boolean isPaused() {
+ return state == State.PAUSED;
+ }
+
+ public synchronized boolean isRunning() {
+ return state == State.RUNNING;
+ }
+
+ public synchronized void stop() {
+ if (state != State.STOPPED) {
+ Log.debug("[EmulationFragment] Stopping emulation.");
+ state = State.STOPPED;
+ NativeLibrary.StopEmulation();
+ } else {
+ Log.warning("[EmulationFragment] Stop called while already stopped.");
+ }
+ }
+
+ // State changing methods
+
+ public synchronized void pause() {
+ if (state != State.PAUSED) {
+ state = State.PAUSED;
+ Log.debug("[EmulationFragment] Pausing emulation.");
+
+ // Release the surface before pausing, since emulation has to be running for that.
+ NativeLibrary.SurfaceDestroyed();
+ NativeLibrary.PauseEmulation();
+ } else {
+ Log.warning("[EmulationFragment] Pause called while already paused.");
+ }
+ }
+
+ public synchronized void run(boolean isActivityRecreated) {
+ 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 (mSurface != null) {
+ runWithValidSurface();
+ } else {
+ mRunWhenSurfaceIsValid = true;
+ }
+ }
+
+ // Surface callbacks
+ public synchronized void newSurface(Surface surface) {
+ mSurface = surface;
+ if (mRunWhenSurfaceIsValid) {
+ runWithValidSurface();
+ }
+ }
+
+ public synchronized void clearSurface() {
+ if (mSurface == null) {
+ Log.warning("[EmulationFragment] clearSurface called, but surface already null.");
+ } else {
+ mSurface = null;
+ Log.debug("[EmulationFragment] Surface destroyed.");
+
+ if (state == State.RUNNING) {
+ NativeLibrary.SurfaceDestroyed();
+ state = State.PAUSED;
+ } else if (state == State.PAUSED) {
+ Log.warning("[EmulationFragment] Surface cleared while emulation paused.");
+ } else {
+ Log.warning("[EmulationFragment] Surface cleared while emulation stopped.");
+ }
+ }
+ }
+
+ private void runWithValidSurface() {
+ mRunWhenSurfaceIsValid = false;
+ if (state == State.STOPPED) {
+ NativeLibrary.SurfaceChanged(mSurface);
+ Thread mEmulationThread = new Thread(() ->
+ {
+ Log.debug("[EmulationFragment] Starting emulation thread.");
+ NativeLibrary.Run(mGamePath);
+ }, "NativeEmulation");
+ mEmulationThread.start();
+
+ } else if (state == State.PAUSED) {
+ Log.debug("[EmulationFragment] Resuming emulation.");
+ NativeLibrary.SurfaceChanged(mSurface);
+ NativeLibrary.UnPauseEmulation();
+ } else {
+ Log.debug("[EmulationFragment] Bug, run called while already running.");
+ }
+ state = State.RUNNING;
+ }
+
+ private enum State {
+ STOPPED, RUNNING, PAUSED
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java
new file mode 100644
index 0000000000..a4ffc59c71
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java
@@ -0,0 +1,76 @@
+package org.citra.citra_emu.model;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+
+import java.nio.file.Paths;
+
+public final class Game {
+ private String mTitle;
+ private String mDescription;
+ private String mPath;
+ private String mGameId;
+ private String mCompany;
+ private String mRegions;
+
+ public Game(String title, String description, String regions, String path,
+ String gameId, String company) {
+ mTitle = title;
+ mDescription = description;
+ mRegions = regions;
+ mPath = path;
+ mGameId = gameId;
+ mCompany = company;
+ }
+
+ public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) {
+ ContentValues values = new ContentValues();
+
+ if (gameId.isEmpty()) {
+ // Homebrew, etc. may not have a game ID, use filename as a unique identifier
+ gameId = Paths.get(path).getFileName().toString();
+ }
+
+ values.put(GameDatabase.KEY_GAME_TITLE, title);
+ values.put(GameDatabase.KEY_GAME_DESCRIPTION, description);
+ values.put(GameDatabase.KEY_GAME_REGIONS, regions);
+ values.put(GameDatabase.KEY_GAME_PATH, path);
+ values.put(GameDatabase.KEY_GAME_ID, gameId);
+ values.put(GameDatabase.KEY_GAME_COMPANY, company);
+
+ return values;
+ }
+
+ public static Game fromCursor(Cursor cursor) {
+ return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE),
+ cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION),
+ cursor.getString(GameDatabase.GAME_COLUMN_REGIONS),
+ cursor.getString(GameDatabase.GAME_COLUMN_PATH),
+ cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
+ cursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
+ }
+
+ public String getTitle() {
+ return mTitle;
+ }
+
+ public String getDescription() {
+ return mDescription;
+ }
+
+ public String getCompany() {
+ return mCompany;
+ }
+
+ public String getRegions() {
+ return mRegions;
+ }
+
+ public String getPath() {
+ return mPath;
+ }
+
+ public String getGameId() {
+ return mGameId;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
new file mode 100644
index 0000000000..8232d0489e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java
@@ -0,0 +1,276 @@
+package org.citra.citra_emu.model;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.utils.Log;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import rx.Observable;
+
+/**
+ * A helper class that provides several utilities simplifying interaction with
+ * the SQLite database.
+ */
+public final class GameDatabase extends SQLiteOpenHelper {
+ public static final int COLUMN_DB_ID = 0;
+ public static final int GAME_COLUMN_PATH = 1;
+ public static final int GAME_COLUMN_TITLE = 2;
+ public static final int GAME_COLUMN_DESCRIPTION = 3;
+ public static final int GAME_COLUMN_REGIONS = 4;
+ public static final int GAME_COLUMN_GAME_ID = 5;
+ public static final int GAME_COLUMN_COMPANY = 6;
+ public static final int FOLDER_COLUMN_PATH = 1;
+ public static final String KEY_DB_ID = "_id";
+ public static final String KEY_GAME_PATH = "path";
+ public static final String KEY_GAME_TITLE = "title";
+ public static final String KEY_GAME_DESCRIPTION = "description";
+ public static final String KEY_GAME_REGIONS = "regions";
+ public static final String KEY_GAME_ID = "game_id";
+ public static final String KEY_GAME_COMPANY = "company";
+ public static final String KEY_FOLDER_PATH = "path";
+ public static final String TABLE_NAME_FOLDERS = "folders";
+ public static final String TABLE_NAME_GAMES = "games";
+ private static final int DB_VERSION = 2;
+ private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY";
+ private static final String TYPE_INTEGER = " INTEGER";
+ private static final String TYPE_STRING = " TEXT";
+
+ private static final String CONSTRAINT_UNIQUE = " UNIQUE";
+
+ private static final String SEPARATOR = ", ";
+
+ private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "("
+ + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
+ + KEY_GAME_PATH + TYPE_STRING + SEPARATOR
+ + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR
+ + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR
+ + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR
+ + KEY_GAME_ID + TYPE_STRING + SEPARATOR
+ + KEY_GAME_COMPANY + TYPE_STRING + ")";
+
+ private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "("
+ + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR
+ + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")";
+
+ private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS;
+ private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES;
+
+ public GameDatabase(Context context) {
+ // Superclass constructor builds a database or uses an existing one.
+ super(context, "games.db", null, DB_VERSION);
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase database) {
+ Log.debug("[GameDatabase] GameDatabase - Creating database...");
+
+ execSqlAndLog(database, SQL_CREATE_GAMES);
+ execSqlAndLog(database, SQL_CREATE_FOLDERS);
+ }
+
+ @Override
+ public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) {
+ Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases..");
+ execSqlAndLog(database, SQL_DELETE_FOLDERS);
+ execSqlAndLog(database, SQL_CREATE_FOLDERS);
+
+ execSqlAndLog(database, SQL_DELETE_GAMES);
+ execSqlAndLog(database, SQL_CREATE_GAMES);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) {
+ Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " +
+ newVersion);
+
+ // Delete all the games
+ execSqlAndLog(database, SQL_DELETE_GAMES);
+ execSqlAndLog(database, SQL_CREATE_GAMES);
+ }
+
+ public void resetDatabase(SQLiteDatabase database) {
+ execSqlAndLog(database, SQL_DELETE_FOLDERS);
+ execSqlAndLog(database, SQL_CREATE_FOLDERS);
+
+ execSqlAndLog(database, SQL_DELETE_GAMES);
+ execSqlAndLog(database, SQL_CREATE_GAMES);
+ }
+
+ public void scanLibrary(SQLiteDatabase database) {
+ // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing.
+ Cursor fileCursor = database.query(TABLE_NAME_GAMES,
+ null, // Get all columns.
+ null, // Get all rows.
+ null,
+ null, // No grouping.
+ null,
+ null); // Order of games is irrelevant.
+
+ // Possibly overly defensive, but ensures that moveToNext() does not skip a row.
+ fileCursor.moveToPosition(-1);
+
+ while (fileCursor.moveToNext()) {
+ String gamePath = fileCursor.getString(GAME_COLUMN_PATH);
+ File game = new File(gamePath);
+
+ if (!game.exists()) {
+ Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " +
+ gamePath);
+ database.delete(TABLE_NAME_GAMES,
+ KEY_DB_ID + " = ?",
+ new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))});
+ }
+ }
+
+ // Get a cursor listing all the folders the user has added to the library.
+ Cursor folderCursor = database.query(TABLE_NAME_FOLDERS,
+ null, // Get all columns.
+ null, // Get all rows.
+ null,
+ null, // No grouping.
+ null,
+ null); // Order of folders is irrelevant.
+
+ Set allowedExtensions = new HashSet(Arrays.asList(
+ ".xci", ".nsp", ".nca", ".nro"));
+
+ // Possibly overly defensive, but ensures that moveToNext() does not skip a row.
+ folderCursor.moveToPosition(-1);
+
+ // Iterate through all results of the DB query (i.e. all folders in the library.)
+ while (folderCursor.moveToNext()) {
+ String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH);
+
+ File folder = new File(folderPath);
+ // If the folder is empty because it no longer exists, remove it from the library.
+ if (!folder.exists()) {
+ Log.error(
+ "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath);
+ database.delete(TABLE_NAME_FOLDERS,
+ KEY_DB_ID + " = ?",
+ new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))});
+ }
+
+ addGamesRecursive(database, folder, allowedExtensions, 3);
+ }
+
+ fileCursor.close();
+ folderCursor.close();
+
+ database.close();
+ }
+
+ private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) {
+ if (depth <= 0) {
+ return;
+ }
+
+ File[] children = parent.listFiles();
+ if (children != null) {
+ for (File file : children) {
+ if (file.isHidden()) {
+ continue;
+ }
+
+ if (file.isDirectory()) {
+ Set newExtensions = new HashSet<>(Arrays.asList(
+ ".xci", ".nsp", ".nca", ".nro"));
+ addGamesRecursive(database, file, newExtensions, depth - 1);
+ } else {
+ String filePath = file.getPath();
+
+ int extensionStart = filePath.lastIndexOf('.');
+ if (extensionStart > 0) {
+ String fileExtension = filePath.substring(extensionStart);
+
+ // Check that the file has an extension we care about before trying to read out of it.
+ if (allowedExtensions.contains(fileExtension.toLowerCase())) {
+ attemptToAddGame(database, filePath);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static void attemptToAddGame(SQLiteDatabase database, String filePath) {
+ String name = NativeLibrary.GetTitle(filePath);
+
+ // If the game's title field is empty, use the filename.
+ if (name.isEmpty()) {
+ name = filePath.substring(filePath.lastIndexOf("/") + 1);
+ }
+
+ String 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("."));
+ }
+
+ ContentValues game = Game.asContentValues(name,
+ NativeLibrary.GetDescription(filePath).replace("\n", " "),
+ NativeLibrary.GetRegions(filePath),
+ filePath,
+ gameId,
+ NativeLibrary.GetCompany(filePath));
+
+ // Try to update an existing game first.
+ int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update.
+ game,
+ // The values to fill the row with.
+ KEY_GAME_ID + " = ?",
+ // The WHERE clause used to find the right row.
+ new String[]{game.getAsString(
+ KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this,
+ // which is provided as an array because there
+ // could potentially be more than one argument.
+
+ // If update fails, insert a new game instead.
+ if (rowsMatched == 0) {
+ Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE));
+ database.insert(TABLE_NAME_GAMES, null, game);
+ } else {
+ Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE));
+ }
+ }
+
+ public Observable getGames() {
+ return Observable.create(subscriber ->
+ {
+ Log.info("[GameDatabase] Reading games list...");
+
+ SQLiteDatabase database = getReadableDatabase();
+ Cursor resultCursor = database.query(
+ TABLE_NAME_GAMES,
+ null,
+ null,
+ null,
+ null,
+ null,
+ KEY_GAME_TITLE + " ASC"
+ );
+
+ // Pass the result cursor to the consumer.
+ subscriber.onNext(resultCursor);
+
+ // Tell the consumer we're done; it will unsubscribe implicitly.
+ subscriber.onCompleted();
+ });
+ }
+
+ private void execSqlAndLog(SQLiteDatabase database, String sql) {
+ Log.verbose("[GameDatabase] Executing SQL: " + sql);
+ database.execSQL(sql);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java
new file mode 100644
index 0000000000..33b289fc41
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java
@@ -0,0 +1,138 @@
+package org.citra.citra_emu.model;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import org.citra.citra_emu.BuildConfig;
+import org.citra.citra_emu.utils.Log;
+
+/**
+ * Provides an interface allowing Activities to interact with the SQLite database.
+ * CRUD methods in this class can be called by Activities using getContentResolver().
+ */
+public final class GameProvider extends ContentProvider {
+ public static final String REFRESH_LIBRARY = "refresh";
+ public static final String RESET_LIBRARY = "reset";
+
+ public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider";
+ public static final Uri URI_FOLDER =
+ Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/");
+ public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/");
+ public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/");
+
+ public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder";
+ public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game";
+
+
+ private GameDatabase mDbHelper;
+
+ @Override
+ public boolean onCreate() {
+ Log.info("[GameProvider] Creating Content Provider...");
+
+ mDbHelper = new GameDatabase(getContext());
+
+ return true;
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ Log.info("[GameProvider] Querying URI: " + uri);
+
+ SQLiteDatabase db = mDbHelper.getReadableDatabase();
+
+ String table = uri.getLastPathSegment();
+
+ if (table == null) {
+ Log.error("[GameProvider] Badly formatted URI: " + uri);
+ return null;
+ }
+
+ Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+
+ return cursor;
+ }
+
+ @Override
+ public String getType(@NonNull Uri uri) {
+ Log.verbose("[GameProvider] Getting MIME type for URI: " + uri);
+ String lastSegment = uri.getLastPathSegment();
+
+ if (lastSegment == null) {
+ Log.error("[GameProvider] Badly formatted URI: " + uri);
+ return null;
+ }
+
+ if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) {
+ return MIME_TYPE_FOLDER;
+ } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) {
+ return MIME_TYPE_GAME;
+ }
+
+ Log.error("[GameProvider] Unknown MIME type for URI: " + uri);
+ return null;
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues values) {
+ Log.info("[GameProvider] Inserting row at URI: " + uri);
+
+ SQLiteDatabase database = mDbHelper.getWritableDatabase();
+ String table = uri.getLastPathSegment();
+
+ if (table != null) {
+ if (table.equals(RESET_LIBRARY)) {
+ mDbHelper.resetDatabase(database);
+ return uri;
+ }
+ if (table.equals(REFRESH_LIBRARY)) {
+ Log.info(
+ "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents...");
+ mDbHelper.scanLibrary(database);
+ return uri;
+ }
+
+ long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE);
+
+ // If insertion was successful...
+ if (id > 0) {
+ // If we just added a folder, add its contents to the game list.
+ if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) {
+ mDbHelper.scanLibrary(database);
+ }
+
+ // Notify the UI that its contents should be refreshed.
+ getContext().getContentResolver().notifyChange(uri, null);
+ uri = Uri.withAppendedPath(uri, Long.toString(id));
+ } else {
+ Log.error("[GameProvider] Row already exists: " + uri + " id: " + id);
+ }
+ } else {
+ Log.error("[GameProvider] Badly formatted URI: " + uri);
+ }
+
+ database.close();
+
+ return uri;
+ }
+
+ @Override
+ public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {
+ Log.error("[GameProvider] Delete operations unsupported. URI: " + uri);
+ return 0;
+ }
+
+ @Override
+ public int update(@NonNull Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ Log.error("[GameProvider] Update operations unsupported. URI: " + uri);
+ return 0;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java
new file mode 100644
index 0000000000..cdb2f76667
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java
@@ -0,0 +1,878 @@
+/**
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.overlay;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.preference.PreferenceManager;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.view.Display;
+import android.view.MotionEvent;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.View.OnTouchListener;
+
+import org.citra.citra_emu.NativeLibrary;
+import org.citra.citra_emu.NativeLibrary.ButtonState;
+import org.citra.citra_emu.NativeLibrary.ButtonType;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Draws the interactive input overlay on top of the
+ * {@link SurfaceView} that is rendering emulation.
+ */
+public final class InputOverlay extends SurfaceView implements OnTouchListener {
+ private final Set overlayButtons = new HashSet<>();
+ private final Set overlayDpads = new HashSet<>();
+ private final Set overlayJoysticks = new HashSet<>();
+
+ private boolean mIsInEditMode = false;
+ private InputOverlayDrawableButton mButtonBeingConfigured;
+ private InputOverlayDrawableDpad mDpadBeingConfigured;
+ private InputOverlayDrawableJoystick mJoystickBeingConfigured;
+
+ private SharedPreferences mPreferences;
+
+ // Stores the ID of the pointer that interacted with the 3DS touchscreen.
+ private int mTouchscreenPointerId = -1;
+
+ /**
+ * Constructor
+ *
+ * @param context The current {@link Context}.
+ * @param attrs {@link AttributeSet} for parsing XML attributes.
+ */
+ public InputOverlay(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext());
+ if (!mPreferences.getBoolean("OverlayInit", false)) {
+ defaultOverlay();
+ }
+
+ // Reset 3ds touchscreen pointer ID
+ mTouchscreenPointerId = -1;
+
+ // 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();
+ }
+
+ /**
+ * Resizes a {@link Bitmap} by a given scale factor
+ *
+ * @param context The current {@link Context}
+ * @param bitmap The {@link Bitmap} to scale.
+ * @param scale The scale factor for the bitmap.
+ * @return The scaled {@link Bitmap}
+ */
+ public static Bitmap resizeBitmap(Context context, Bitmap bitmap, float scale) {
+ // Determine the button size based on the smaller screen dimension.
+ // This makes sure the buttons are the same size in both portrait and landscape.
+ DisplayMetrics dm = context.getResources().getDisplayMetrics();
+ int minDimension = Math.min(dm.widthPixels, dm.heightPixels);
+
+ return Bitmap.createScaledBitmap(bitmap,
+ (int) (minDimension * scale),
+ (int) (minDimension * scale),
+ true);
+ }
+
+ /**
+ * 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 {@link 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 Citra 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 {@link Context}.
+ * @param defaultResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Default State).
+ * @param pressedResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Pressed State).
+ * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents.
+ * @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set.
+ */
+ private static InputOverlayDrawableButton initializeOverlayButton(Context context,
+ int defaultResId, int pressedResId, int buttonId, String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on button ID and user preference
+ float scale;
+
+ switch (buttonId) {
+ case ButtonType.BUTTON_HOME:
+ case ButtonType.BUTTON_START:
+ case ButtonType.BUTTON_SELECT:
+ scale = 0.08f;
+ break;
+ case ButtonType.TRIGGER_L:
+ case ButtonType.TRIGGER_R:
+ case ButtonType.BUTTON_ZL:
+ case ButtonType.BUTTON_ZR:
+ scale = 0.18f;
+ break;
+ default:
+ scale = 0.11f;
+ break;
+ }
+
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableButton.
+ final Bitmap defaultStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale);
+ final Bitmap pressedStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedResId), scale);
+ final InputOverlayDrawableButton overlayDrawable =
+ new InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId);
+
+ // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ String xKey;
+ String yKey;
+
+ xKey = buttonId + orientation + "-X";
+ yKey = buttonId + orientation + "-Y";
+
+ int drawableX = (int) sPrefs.getFloat(xKey, 0f);
+ int drawableY = (int) sPrefs.getFloat(yKey, 0f);
+
+ int width = overlayDrawable.getWidth();
+ int height = overlayDrawable.getHeight();
+
+ // 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, drawableY, drawableX + width, drawableY + height);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ /**
+ * Initializes an {@link InputOverlayDrawableDpad}
+ *
+ * @param context The current {@link Context}.
+ * @param defaultResId The {@link Bitmap} resource ID of the default sate.
+ * @param pressedOneDirectionResId The {@link Bitmap} resource ID of the pressed sate in one direction.
+ * @param pressedTwoDirectionsResId The {@link Bitmap} resource ID of the pressed sate in two directions.
+ * @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.
+ * @return the initialized {@link InputOverlayDrawableDpad}
+ */
+ private static InputOverlayDrawableDpad initializeOverlayDpad(Context context,
+ int defaultResId,
+ int pressedOneDirectionResId,
+ int pressedTwoDirectionsResId,
+ int buttonUp,
+ int buttonDown,
+ int buttonLeft,
+ int buttonRight,
+ String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on button ID and user preference
+ float scale = 0.22f;
+
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableDpad.
+ final Bitmap defaultStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale);
+ final Bitmap pressedOneDirectionStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedOneDirectionResId),
+ scale);
+ final Bitmap pressedTwoDirectionsStateBitmap =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, pressedTwoDirectionsResId),
+ scale);
+ final InputOverlayDrawableDpad overlayDrawable =
+ new InputOverlayDrawableDpad(res, defaultStateBitmap,
+ pressedOneDirectionStateBitmap, pressedTwoDirectionsStateBitmap,
+ buttonUp, buttonDown, buttonLeft, buttonRight);
+
+ // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ int drawableX = (int) sPrefs.getFloat(buttonUp + orientation + "-X", 0f);
+ int drawableY = (int) sPrefs.getFloat(buttonUp + orientation + "-Y", 0f);
+
+ int width = overlayDrawable.getWidth();
+ int height = overlayDrawable.getHeight();
+
+ // 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, drawableY, drawableX + width, drawableY + height);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ /**
+ * Initializes an {@link InputOverlayDrawableJoystick}
+ *
+ * @param context The current {@link Context}
+ * @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.
+ * @return the initialized {@link InputOverlayDrawableJoystick}.
+ */
+ private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context,
+ int resOuter, int defaultResInner, int pressedResInner, int joystick, String orientation) {
+ // Resources handle for fetching the initial Drawable resource.
+ final Resources res = context.getResources();
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context);
+
+ // Decide scale based on user preference
+ float scale = 0.275f;
+ scale *= (sPrefs.getInt("controlScale", 50) + 50);
+ scale /= 100;
+
+ // Initialize the InputOverlayDrawableJoystick.
+ final Bitmap bitmapOuter =
+ resizeBitmap(context, BitmapFactory.decodeResource(res, resOuter), scale);
+ final Bitmap bitmapInnerDefault = BitmapFactory.decodeResource(res, defaultResInner);
+ final Bitmap bitmapInnerPressed = BitmapFactory.decodeResource(res, pressedResInner);
+
+ // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ int drawableX = (int) sPrefs.getFloat(joystick + orientation + "-X", 0f);
+ int drawableY = (int) sPrefs.getFloat(joystick + orientation + "-Y", 0f);
+
+ // Decide inner scale based on joystick ID
+ float outerScale = 1.f;
+ if (joystick == ButtonType.STICK_C) {
+ outerScale = 2.f;
+ }
+
+ // Now set the bounds for the InputOverlayDrawableJoystick.
+ // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be.
+ int outerSize = bitmapOuter.getWidth();
+ Rect outerRect = new Rect(drawableX, drawableY, drawableX + (int) (outerSize / outerScale), drawableY + (int) (outerSize / outerScale));
+ Rect innerRect = new Rect(0, 0, (int) (outerSize / outerScale), (int) (outerSize / outerScale));
+
+ // Send the drawableId to the joystick so it can be referenced when saving control position.
+ final InputOverlayDrawableJoystick overlayDrawable
+ = new InputOverlayDrawableJoystick(res, bitmapOuter,
+ bitmapInnerDefault, bitmapInnerPressed,
+ outerRect, innerRect, joystick);
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY);
+
+ return overlayDrawable;
+ }
+
+ @Override
+ public void draw(Canvas canvas) {
+ super.draw(canvas);
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ button.draw(canvas);
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ dpad.draw(canvas);
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ joystick.draw(canvas);
+ }
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (isInEditMode()) {
+ return onTouchWhileEditing(event);
+ }
+
+ int pointerIndex = event.getActionIndex();
+
+ if (mPreferences.getBoolean("isTouchEnabled", true)) {
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (NativeLibrary.onTouchEvent(event.getX(pointerIndex), event.getY(pointerIndex), true)) {
+ mTouchscreenPointerId = event.getPointerId(pointerIndex);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mTouchscreenPointerId == event.getPointerId(pointerIndex)) {
+ // We don't really care where the touch has been released. We only care whether it has been
+ // released or not.
+ NativeLibrary.onTouchEvent(0, 0, false);
+ mTouchscreenPointerId = -1;
+ }
+ break;
+ }
+
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (mTouchscreenPointerId == event.getPointerId(i)) {
+ NativeLibrary.onTouchMoved(event.getX(i), event.getY(i));
+ }
+ }
+ }
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If a pointer enters the bounds of a button, press that button.
+ if (button.getBounds()
+ .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ button.setPressedState(true);
+ button.setTrackId(event.getPointerId(pointerIndex));
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(),
+ ButtonState.PRESSED);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ // If a pointer ends, release the button it was pressing.
+ if (button.getTrackId() == event.getPointerId(pointerIndex)) {
+ button.setPressedState(false);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(),
+ ButtonState.RELEASED);
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If a pointer enters the bounds of a button, press that button.
+ if (dpad.getBounds()
+ .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ dpad.setTrackId(event.getPointerId(pointerIndex));
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ // If a pointer ends, release the buttons.
+ if (dpad.getTrackId() == event.getPointerId(pointerIndex)) {
+ for (int i = 0; i < 4; i++) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(i),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ dpad.setTrackId(-1);
+ }
+ break;
+ }
+
+ if (dpad.getTrackId() != -1) {
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (dpad.getTrackId() == event.getPointerId(i)) {
+ float touchX = event.getX(i);
+ float touchY = event.getY(i);
+ float maxY = dpad.getBounds().bottom;
+ float maxX = dpad.getBounds().right;
+ touchX -= dpad.getBounds().centerX();
+ maxX -= dpad.getBounds().centerX();
+ touchY -= dpad.getBounds().centerY();
+ maxY -= dpad.getBounds().centerY();
+ final float AxisX = touchX / maxX;
+ final float AxisY = touchY / maxY;
+
+ boolean up = false;
+ boolean down = false;
+ boolean left = false;
+ boolean right = false;
+ if (EmulationMenuSettings.getDpadSlideEnable() ||
+ (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN ||
+ (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_DOWN) {
+ if (AxisY < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0),
+ NativeLibrary.ButtonState.PRESSED);
+ up = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisY > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1),
+ NativeLibrary.ButtonState.PRESSED);
+ down = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisX < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2),
+ NativeLibrary.ButtonState.PRESSED);
+ left = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+ if (AxisX > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3),
+ NativeLibrary.ButtonState.PRESSED);
+ right = true;
+ } else {
+ NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3),
+ NativeLibrary.ButtonState.RELEASED);
+ }
+
+ // Set state
+ if (up) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP);
+ } else if (down) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN);
+ } else if (left) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT);
+ } else if (right) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT);
+ } else {
+ dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ joystick.TrackEvent(event);
+ int axisID = joystick.getId();
+ float[] axises = joystick.getAxisValues();
+
+ NativeLibrary
+ .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, axises[0], axises[1]);
+ }
+
+ invalidate();
+
+ return true;
+ }
+
+ public boolean onTouchWhileEditing(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+
+ String orientation =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ?
+ "-Portrait" : "";
+
+ // Maybe combine Button and Joystick as subclasses of the same parent?
+ // Or maybe create an interface like IMoveableHUDControl?
+
+ for (InputOverlayDrawableButton button : overlayButtons) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If no button is being moved now, remember the currently touched button to move.
+ if (mButtonBeingConfigured == null &&
+ button.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mButtonBeingConfigured = button;
+ mButtonBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mButtonBeingConfigured != null) {
+ mButtonBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ return true;
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mButtonBeingConfigured == button) {
+ // Persist button position by saving new place.
+ saveControlPosition(mButtonBeingConfigured.getId(),
+ mButtonBeingConfigured.getBounds().left,
+ mButtonBeingConfigured.getBounds().top, orientation);
+ mButtonBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableDpad dpad : overlayDpads) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ // If no button is being moved now, remember the currently touched button to move.
+ if (mButtonBeingConfigured == null &&
+ dpad.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mDpadBeingConfigured = dpad;
+ mDpadBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mDpadBeingConfigured != null) {
+ mDpadBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ return true;
+ }
+ break;
+
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mDpadBeingConfigured == dpad) {
+ // Persist button position by saving new place.
+ saveControlPosition(mDpadBeingConfigured.getId(0),
+ mDpadBeingConfigured.getBounds().left, mDpadBeingConfigured.getBounds().top,
+ orientation);
+ mDpadBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ for (InputOverlayDrawableJoystick joystick : overlayJoysticks) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (mJoystickBeingConfigured == null &&
+ joystick.getBounds().contains(fingerPositionX, fingerPositionY)) {
+ mJoystickBeingConfigured = joystick;
+ mJoystickBeingConfigured.onConfigureTouch(event);
+ }
+ break;
+ case MotionEvent.ACTION_MOVE:
+ if (mJoystickBeingConfigured != null) {
+ mJoystickBeingConfigured.onConfigureTouch(event);
+ invalidate();
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (mJoystickBeingConfigured != null) {
+ saveControlPosition(mJoystickBeingConfigured.getId(),
+ mJoystickBeingConfigured.getBounds().left,
+ mJoystickBeingConfigured.getBounds().top, orientation);
+ mJoystickBeingConfigured = null;
+ }
+ break;
+ }
+ }
+
+ return true;
+ }
+
+ private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left,
+ boolean right) {
+ if (up) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP);
+ } else if (down) {
+ if (left)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT);
+ else if (right)
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT);
+ else
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN);
+ } else if (left) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT);
+ } else if (right) {
+ dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT);
+ }
+ }
+
+ private void addOverlayControls(String orientation) {
+ if (mPreferences.getBoolean("buttonToggle0", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_a,
+ R.drawable.button_a_pressed, ButtonType.BUTTON_A, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle1", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_b,
+ R.drawable.button_b_pressed, ButtonType.BUTTON_B, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle2", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_x,
+ R.drawable.button_x_pressed, ButtonType.BUTTON_X, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle3", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_y,
+ R.drawable.button_y_pressed, ButtonType.BUTTON_Y, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle4", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l,
+ R.drawable.button_l_pressed, ButtonType.TRIGGER_L, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle5", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r,
+ R.drawable.button_r_pressed, ButtonType.TRIGGER_R, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle6", false)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zl,
+ R.drawable.button_zl_pressed, ButtonType.BUTTON_ZL, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle7", false)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zr,
+ R.drawable.button_zr_pressed, ButtonType.BUTTON_ZR, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle8", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start,
+ R.drawable.button_start_pressed, ButtonType.BUTTON_START, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle9", true)) {
+ overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select,
+ R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle10", true)) {
+ overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad,
+ R.drawable.dpad_pressed_one_direction,
+ R.drawable.dpad_pressed_two_directions,
+ ButtonType.DPAD_UP, ButtonType.DPAD_DOWN,
+ ButtonType.DPAD_LEFT, ButtonType.DPAD_RIGHT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle11", true)) {
+ overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_main_range,
+ R.drawable.stick_main, R.drawable.stick_main_pressed,
+ ButtonType.STICK_LEFT, orientation));
+ }
+ if (mPreferences.getBoolean("buttonToggle12", false)) {
+ overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_c_range,
+ R.drawable.stick_c, R.drawable.stick_c_pressed, ButtonType.STICK_C, orientation));
+ }
+ }
+
+ public void refreshControls() {
+ // Remove all the overlay buttons from the HashSet.
+ overlayButtons.clear();
+ overlayDpads.clear();
+ overlayJoysticks.clear();
+
+ String orientation =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ?
+ "-Portrait" : "";
+
+ // Add all the enabled overlay items back to the HashSet.
+ if (EmulationMenuSettings.getShowOverlay()) {
+ addOverlayControls(orientation);
+ }
+
+ invalidate();
+ }
+
+ private void saveControlPosition(int sharedPrefsId, int x, int y, String orientation) {
+ final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext());
+ SharedPreferences.Editor sPrefsEditor = sPrefs.edit();
+ sPrefsEditor.putFloat(sharedPrefsId + orientation + "-X", x);
+ sPrefsEditor.putFloat(sharedPrefsId + orientation + "-Y", y);
+ sPrefsEditor.apply();
+ }
+
+ public void setIsInEditMode(boolean isInEditMode) {
+ mIsInEditMode = isInEditMode;
+ }
+
+ private void defaultOverlay() {
+ if (!mPreferences.getBoolean("OverlayInit", false)) {
+ // It's possible that a user has created their overlay before this was added
+ // Only change the overlay if the 'A' button is not in the upper corner.
+ if (mPreferences.getFloat(ButtonType.BUTTON_A + "-X", 0f) == 0f) {
+ defaultOverlayLandscape();
+ }
+ if (mPreferences.getFloat(ButtonType.BUTTON_A + "-Portrait" + "-X", 0f) == 0f) {
+ defaultOverlayPortrait();
+ }
+ }
+
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ sPrefsEditor.putBoolean("OverlayInit", true);
+ sPrefsEditor.apply();
+ }
+
+ public void resetButtonPlacement() {
+ boolean isLandscape =
+ getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+
+ if (isLandscape) {
+ defaultOverlayLandscape();
+ } else {
+ defaultOverlayPortrait();
+ }
+
+ refreshControls();
+ }
+
+ private void defaultOverlayLandscape() {
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ // Get screen size
+ Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
+ DisplayMetrics outMetrics = new DisplayMetrics();
+ display.getMetrics(outMetrics);
+ float maxX = outMetrics.heightPixels;
+ float maxY = outMetrics.widthPixels;
+ // Height and width changes depending on orientation. Use the larger value for height.
+ if (maxY > maxX) {
+ float tmp = maxX;
+ maxX = maxY;
+ maxY = tmp;
+ }
+ Resources res = getResources();
+
+ // Each value is a percent from max X/Y stored as an int. Have to bring that value down
+ // to a decimal before multiplying by MAX X/Y.
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_Y) / 1000) * maxY));
+
+ // We want to commit right away, otherwise the overlay could load before this is saved.
+ sPrefsEditor.commit();
+ }
+
+ private void defaultOverlayPortrait() {
+ SharedPreferences.Editor sPrefsEditor = mPreferences.edit();
+ // Get screen size
+ Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay();
+ DisplayMetrics outMetrics = new DisplayMetrics();
+ display.getMetrics(outMetrics);
+ float maxX = outMetrics.heightPixels;
+ float maxY = outMetrics.widthPixels;
+ // Height and width changes depending on orientation. Use the larger value for height.
+ if (maxY < maxX) {
+ float tmp = maxX;
+ maxX = maxY;
+ maxY = tmp;
+ }
+ Resources res = getResources();
+ String portrait = "-Portrait";
+
+ // Each value is a percent from max X/Y stored as an int. Have to bring that value down
+ // to a decimal before multiplying by MAX X/Y.
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_Y) / 1000) * maxY));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_X) / 1000) * maxX));
+ sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_Y) / 1000) * maxY));
+
+ // We want to commit right away, otherwise the overlay could load before this is saved.
+ sPrefsEditor.commit();
+ }
+
+ public boolean isInEditMode() {
+ return mIsInEditMode;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java
new file mode 100644
index 0000000000..81352296c9
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java
@@ -0,0 +1,122 @@
+/**
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_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;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableButton {
+ // The ID identifying what type of button this Drawable represents.
+ private int mButtonType;
+ private int mTrackId;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mControlPositionX, mControlPositionY;
+ private int mWidth;
+ private int mHeight;
+ private BitmapDrawable mDefaultStateBitmap;
+ private BitmapDrawable mPressedStateBitmap;
+ private boolean mPressedState = false;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param defaultStateBitmap {@link Bitmap} to use with the default state Drawable.
+ * @param pressedStateBitmap {@link Bitmap} to use with the pressed state Drawable.
+ * @param buttonType Identifier for this type of button.
+ */
+ public InputOverlayDrawableButton(Resources res, Bitmap defaultStateBitmap,
+ Bitmap pressedStateBitmap, int buttonType) {
+ mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap);
+ mPressedStateBitmap = new BitmapDrawable(res, pressedStateBitmap);
+ mButtonType = buttonType;
+
+ mWidth = mDefaultStateBitmap.getIntrinsicWidth();
+ mHeight = mDefaultStateBitmap.getIntrinsicHeight();
+ }
+
+ /**
+ * Gets this InputOverlayDrawableButton's button ID.
+ *
+ * @return this InputOverlayDrawableButton's button ID.
+ */
+ public int getId() {
+ return mButtonType;
+ }
+
+ public int getTrackId() {
+ return mTrackId;
+ }
+
+ public void setTrackId(int trackId) {
+ mTrackId = trackId;
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ mControlPositionX += fingerPositionX - mPreviousTouchX;
+ mControlPositionY += fingerPositionY - mPreviousTouchY;
+ setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX,
+ getHeight() + mControlPositionY);
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+
+ }
+ return true;
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ public void draw(Canvas canvas) {
+ getCurrentStateBitmapDrawable().draw(canvas);
+ }
+
+ private BitmapDrawable getCurrentStateBitmapDrawable() {
+ return mPressedState ? mPressedStateBitmap : mDefaultStateBitmap;
+ }
+
+ public void setBounds(int left, int top, int right, int bottom) {
+ mDefaultStateBitmap.setBounds(left, top, right, bottom);
+ mPressedStateBitmap.setBounds(left, top, right, bottom);
+ }
+
+ public Rect getBounds() {
+ return mDefaultStateBitmap.getBounds();
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+
+ public void setPressedState(boolean isPressed) {
+ mPressedState = isPressed;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java
new file mode 100644
index 0000000000..87f3b7cd96
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java
@@ -0,0 +1,193 @@
+/**
+ * Copyright 2016 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_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;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableDpad {
+ public static final int STATE_DEFAULT = 0;
+ public static final int STATE_PRESSED_UP = 1;
+ public static final int STATE_PRESSED_DOWN = 2;
+ public static final int STATE_PRESSED_LEFT = 3;
+ public static final int STATE_PRESSED_RIGHT = 4;
+ public static final int STATE_PRESSED_UP_LEFT = 5;
+ public static final int STATE_PRESSED_UP_RIGHT = 6;
+ public static final int STATE_PRESSED_DOWN_LEFT = 7;
+ public static final int STATE_PRESSED_DOWN_RIGHT = 8;
+ public static final float VIRT_AXIS_DEADZONE = 0.5f;
+ // The ID identifying what type of button this Drawable represents.
+ private int[] mButtonType = new int[4];
+ private int mTrackId;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mControlPositionX, mControlPositionY;
+ private int mWidth;
+ private int mHeight;
+ private BitmapDrawable mDefaultStateBitmap;
+ private BitmapDrawable mPressedOneDirectionStateBitmap;
+ private BitmapDrawable mPressedTwoDirectionsStateBitmap;
+ private int mPressState = STATE_DEFAULT;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param defaultStateBitmap {@link Bitmap} of the default state.
+ * @param pressedOneDirectionStateBitmap {@link Bitmap} of the pressed state in one direction.
+ * @param pressedTwoDirectionsStateBitmap {@link 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.
+ */
+ public InputOverlayDrawableDpad(Resources res,
+ Bitmap defaultStateBitmap,
+ Bitmap pressedOneDirectionStateBitmap,
+ Bitmap pressedTwoDirectionsStateBitmap,
+ int buttonUp, int buttonDown,
+ int buttonLeft, int buttonRight) {
+ mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap);
+ mPressedOneDirectionStateBitmap = new BitmapDrawable(res, pressedOneDirectionStateBitmap);
+ mPressedTwoDirectionsStateBitmap = new BitmapDrawable(res, pressedTwoDirectionsStateBitmap);
+
+ mWidth = mDefaultStateBitmap.getIntrinsicWidth();
+ mHeight = mDefaultStateBitmap.getIntrinsicHeight();
+
+ mButtonType[0] = buttonUp;
+ mButtonType[1] = buttonDown;
+ mButtonType[2] = buttonLeft;
+ mButtonType[3] = buttonRight;
+
+ mTrackId = -1;
+ }
+
+ public void draw(Canvas canvas) {
+ int px = mControlPositionX + (getWidth() / 2);
+ int py = mControlPositionY + (getHeight() / 2);
+ switch (mPressState) {
+ case STATE_DEFAULT:
+ mDefaultStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_UP:
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_RIGHT:
+ canvas.save();
+ canvas.rotate(90, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN:
+ canvas.save();
+ canvas.rotate(180, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_LEFT:
+ canvas.save();
+ canvas.rotate(270, px, py);
+ mPressedOneDirectionStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_UP_LEFT:
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ break;
+ case STATE_PRESSED_UP_RIGHT:
+ canvas.save();
+ canvas.rotate(90, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN_RIGHT:
+ canvas.save();
+ canvas.rotate(180, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ case STATE_PRESSED_DOWN_LEFT:
+ canvas.save();
+ canvas.rotate(270, px, py);
+ mPressedTwoDirectionsStateBitmap.draw(canvas);
+ canvas.restore();
+ break;
+ }
+ }
+
+ /**
+ * Gets one of the InputOverlayDrawableDpad's button IDs.
+ *
+ * @return the requested InputOverlayDrawableDpad's button ID.
+ */
+ public int getId(int direction) {
+ return mButtonType[direction];
+ }
+
+ public int getTrackId() {
+ return mTrackId;
+ }
+
+ public void setTrackId(int trackId) {
+ mTrackId = trackId;
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ mControlPositionX += fingerPositionX - mPreviousTouchX;
+ mControlPositionY += fingerPositionY - mPreviousTouchY;
+ setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX,
+ getHeight() + mControlPositionY);
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+
+ }
+ return true;
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ public void setBounds(int left, int top, int right, int bottom) {
+ mDefaultStateBitmap.setBounds(left, top, right, bottom);
+ mPressedOneDirectionStateBitmap.setBounds(left, top, right, bottom);
+ mPressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom);
+ }
+
+ public Rect getBounds() {
+ return mDefaultStateBitmap.getBounds();
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+
+ public void setState(int pressState) {
+ mPressState = pressState;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java
new file mode 100644
index 0000000000..956a8b1e90
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java
@@ -0,0 +1,264 @@
+/**
+ * Copyright 2013 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_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.citra.citra_emu.NativeLibrary.ButtonType;
+import org.citra.citra_emu.utils.EmulationMenuSettings;
+
+/**
+ * Custom {@link BitmapDrawable} that is capable
+ * of storing it's own ID.
+ */
+public final class InputOverlayDrawableJoystick {
+ private final int[] axisIDs = {0, 0, 0, 0};
+ private final float[] axises = {0f, 0f};
+ private int trackId = -1;
+ private int mJoystickType;
+ private int mControlPositionX, mControlPositionY;
+ private int mPreviousTouchX, mPreviousTouchY;
+ private int mWidth;
+ private int mHeight;
+ private Rect mVirtBounds;
+ private Rect mOrigBounds;
+ private BitmapDrawable mOuterBitmap;
+ private BitmapDrawable mDefaultStateInnerBitmap;
+ private BitmapDrawable mPressedStateInnerBitmap;
+ private BitmapDrawable mBoundsBoxBitmap;
+ private boolean mPressedState = false;
+
+ /**
+ * Constructor
+ *
+ * @param res {@link Resources} instance.
+ * @param bitmapOuter {@link Bitmap} which represents the outer non-movable part of the joystick.
+ * @param bitmapInnerDefault {@link Bitmap} which represents the default inner movable part of the joystick.
+ * @param bitmapInnerPressed {@link Bitmap} which represents the pressed inner movable part of the joystick.
+ * @param rectOuter {@link Rect} which represents the outer joystick bounds.
+ * @param rectInner {@link Rect} which represents the inner joystick bounds.
+ * @param joystick Identifier for which joystick this is.
+ */
+ public InputOverlayDrawableJoystick(Resources res, Bitmap bitmapOuter,
+ Bitmap bitmapInnerDefault, Bitmap bitmapInnerPressed,
+ Rect rectOuter, Rect rectInner, int joystick) {
+ axisIDs[0] = joystick + 1; // Up
+ axisIDs[1] = joystick + 2; // Down
+ axisIDs[2] = joystick + 3; // Left
+ axisIDs[3] = joystick + 4; // Right
+ mJoystickType = joystick;
+
+ mOuterBitmap = new BitmapDrawable(res, bitmapOuter);
+ mDefaultStateInnerBitmap = new BitmapDrawable(res, bitmapInnerDefault);
+ mPressedStateInnerBitmap = new BitmapDrawable(res, bitmapInnerPressed);
+ mBoundsBoxBitmap = new BitmapDrawable(res, bitmapOuter);
+ mWidth = bitmapOuter.getWidth();
+ mHeight = bitmapOuter.getHeight();
+
+ setBounds(rectOuter);
+ mDefaultStateInnerBitmap.setBounds(rectInner);
+ mPressedStateInnerBitmap.setBounds(rectInner);
+ mVirtBounds = getBounds();
+ mOrigBounds = mOuterBitmap.copyBounds();
+ mBoundsBoxBitmap.setAlpha(0);
+ mBoundsBoxBitmap.setBounds(getVirtBounds());
+ SetInnerBounds();
+ }
+
+ /**
+ * Gets this InputOverlayDrawableJoystick's button ID.
+ *
+ * @return this InputOverlayDrawableJoystick's button ID.
+ */
+ public int getId() {
+ return mJoystickType;
+ }
+
+ public void draw(Canvas canvas) {
+ mOuterBitmap.draw(canvas);
+ getCurrentStateBitmapDrawable().draw(canvas);
+ mBoundsBoxBitmap.draw(canvas);
+ }
+
+ public void TrackEvent(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+
+ switch (event.getAction() & MotionEvent.ACTION_MASK) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (getBounds().contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) {
+ mPressedState = true;
+ mOuterBitmap.setAlpha(0);
+ mBoundsBoxBitmap.setAlpha(255);
+ if (EmulationMenuSettings.getJoystickRelCenter()) {
+ getVirtBounds().offset((int) event.getX(pointerIndex) - getVirtBounds().centerX(),
+ (int) event.getY(pointerIndex) - getVirtBounds().centerY());
+ }
+ mBoundsBoxBitmap.setBounds(getVirtBounds());
+ trackId = event.getPointerId(pointerIndex);
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_POINTER_UP:
+ if (trackId == event.getPointerId(pointerIndex)) {
+ mPressedState = false;
+ axises[0] = axises[1] = 0.0f;
+ mOuterBitmap.setAlpha(255);
+ mBoundsBoxBitmap.setAlpha(0);
+ setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
+ mOrigBounds.bottom));
+ setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right,
+ mOrigBounds.bottom));
+ SetInnerBounds();
+ trackId = -1;
+ }
+ break;
+ }
+
+ if (trackId == -1)
+ return;
+
+ for (int i = 0; i < event.getPointerCount(); i++) {
+ if (trackId == event.getPointerId(i)) {
+ float touchX = event.getX(i);
+ float touchY = event.getY(i);
+ float maxY = getVirtBounds().bottom;
+ float maxX = getVirtBounds().right;
+ touchX -= getVirtBounds().centerX();
+ maxX -= getVirtBounds().centerX();
+ touchY -= getVirtBounds().centerY();
+ maxY -= getVirtBounds().centerY();
+ final float AxisX = touchX / maxX;
+ final float AxisY = touchY / maxY;
+
+ // Clamp the circle pad input to a circle
+ final float angle = (float) Math.atan2(AxisY, AxisX);
+ float radius = (float) Math.sqrt(AxisX * AxisX + AxisY * AxisY);
+ if(radius > 1.0f)
+ {
+ radius = 1.0f;
+ }
+ axises[0] = ((float)Math.cos(angle) * radius);
+ axises[1] = ((float)Math.sin(angle) * radius);
+ SetInnerBounds();
+ }
+ }
+ }
+
+ public boolean onConfigureTouch(MotionEvent event) {
+ int pointerIndex = event.getActionIndex();
+ int fingerPositionX = (int) event.getX(pointerIndex);
+ int fingerPositionY = (int) event.getY(pointerIndex);
+
+ int scale = 1;
+ if (mJoystickType == ButtonType.STICK_C) {
+ // C-stick is scaled down to be half the size of the circle pad
+ scale = 2;
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ case MotionEvent.ACTION_MOVE:
+ int deltaX = fingerPositionX - mPreviousTouchX;
+ int deltaY = fingerPositionY - mPreviousTouchY;
+ mControlPositionX += deltaX;
+ mControlPositionY += deltaY;
+ setBounds(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY));
+ setVirtBounds(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY));
+ SetInnerBounds();
+ setOrigBounds(new Rect(new Rect(mControlPositionX, mControlPositionY,
+ mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX,
+ mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)));
+ mPreviousTouchX = fingerPositionX;
+ mPreviousTouchY = fingerPositionY;
+ break;
+ }
+ return true;
+ }
+
+
+ public float[] getAxisValues() {
+ return axises;
+ }
+
+ public int[] getAxisIDs() {
+ return axisIDs;
+ }
+
+ private void SetInnerBounds() {
+ int X = getVirtBounds().centerX() + (int) ((axises[0]) * (getVirtBounds().width() / 2));
+ int Y = getVirtBounds().centerY() + (int) ((axises[1]) * (getVirtBounds().height() / 2));
+
+ if (mJoystickType == ButtonType.STICK_LEFT) {
+ X += 1;
+ Y += 1;
+ }
+
+ if (X > getVirtBounds().centerX() + (getVirtBounds().width() / 2))
+ X = getVirtBounds().centerX() + (getVirtBounds().width() / 2);
+ if (X < getVirtBounds().centerX() - (getVirtBounds().width() / 2))
+ X = getVirtBounds().centerX() - (getVirtBounds().width() / 2);
+ if (Y > getVirtBounds().centerY() + (getVirtBounds().height() / 2))
+ Y = getVirtBounds().centerY() + (getVirtBounds().height() / 2);
+ if (Y < getVirtBounds().centerY() - (getVirtBounds().height() / 2))
+ Y = getVirtBounds().centerY() - (getVirtBounds().height() / 2);
+
+ int width = mPressedStateInnerBitmap.getBounds().width() / 2;
+ int height = mPressedStateInnerBitmap.getBounds().height() / 2;
+ mDefaultStateInnerBitmap.setBounds(X - width, Y - height, X + width, Y + height);
+ mPressedStateInnerBitmap.setBounds(mDefaultStateInnerBitmap.getBounds());
+ }
+
+ public void setPosition(int x, int y) {
+ mControlPositionX = x;
+ mControlPositionY = y;
+ }
+
+ private BitmapDrawable getCurrentStateBitmapDrawable() {
+ return mPressedState ? mPressedStateInnerBitmap : mDefaultStateInnerBitmap;
+ }
+
+ public Rect getBounds() {
+ return mOuterBitmap.getBounds();
+ }
+
+ public void setBounds(Rect bounds) {
+ mOuterBitmap.setBounds(bounds);
+ }
+
+ private void setOrigBounds(Rect bounds) {
+ mOrigBounds = bounds;
+ }
+
+ private Rect getVirtBounds() {
+ return mVirtBounds;
+ }
+
+ private void setVirtBounds(Rect bounds) {
+ mVirtBounds = bounds;
+ }
+
+ public int getWidth() {
+ return mWidth;
+ }
+
+ public int getHeight() {
+ return mHeight;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java
new file mode 100644
index 0000000000..96ccc08bb6
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java
@@ -0,0 +1,130 @@
+package org.citra.citra_emu.ui;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * Implementation from:
+ * https://gist.github.com/lapastillaroja/858caf1a82791b6c1a36
+ */
+public class DividerItemDecoration extends RecyclerView.ItemDecoration {
+
+ private Drawable mDivider;
+ private boolean mShowFirstDivider = false;
+ private boolean mShowLastDivider = false;
+
+ public DividerItemDecoration(Context context, AttributeSet attrs) {
+ final TypedArray a = context
+ .obtainStyledAttributes(attrs, new int[]{android.R.attr.listDivider});
+ mDivider = a.getDrawable(0);
+ a.recycle();
+ }
+
+ public DividerItemDecoration(Context context, AttributeSet attrs, boolean showFirstDivider,
+ boolean showLastDivider) {
+ this(context, attrs);
+ mShowFirstDivider = showFirstDivider;
+ mShowLastDivider = showLastDivider;
+ }
+
+ public DividerItemDecoration(Drawable divider) {
+ mDivider = divider;
+ }
+
+ public DividerItemDecoration(Drawable divider, boolean showFirstDivider,
+ boolean showLastDivider) {
+ this(divider);
+ mShowFirstDivider = showFirstDivider;
+ mShowLastDivider = showLastDivider;
+ }
+
+ @Override
+ public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
+ @NonNull RecyclerView.State state) {
+ super.getItemOffsets(outRect, view, parent, state);
+ if (mDivider == null) {
+ return;
+ }
+ if (parent.getChildAdapterPosition(view) < 1) {
+ return;
+ }
+
+ if (getOrientation(parent) == LinearLayoutManager.VERTICAL) {
+ outRect.top = mDivider.getIntrinsicHeight();
+ } else {
+ outRect.left = mDivider.getIntrinsicWidth();
+ }
+ }
+
+ @Override
+ public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) {
+ if (mDivider == null) {
+ super.onDrawOver(c, parent, state);
+ return;
+ }
+
+ // Initialization needed to avoid compiler warning
+ int left = 0, right = 0, top = 0, bottom = 0, size;
+ int orientation = getOrientation(parent);
+ int childCount = parent.getChildCount();
+
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ size = mDivider.getIntrinsicHeight();
+ left = parent.getPaddingLeft();
+ right = parent.getWidth() - parent.getPaddingRight();
+ } else { //horizontal
+ size = mDivider.getIntrinsicWidth();
+ top = parent.getPaddingTop();
+ bottom = parent.getHeight() - parent.getPaddingBottom();
+ }
+
+ for (int i = mShowFirstDivider ? 0 : 1; i < childCount; i++) {
+ View child = parent.getChildAt(i);
+ RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
+
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ top = child.getTop() - params.topMargin;
+ bottom = top + size;
+ } else { //horizontal
+ left = child.getLeft() - params.leftMargin;
+ right = left + size;
+ }
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+
+ // show last divider
+ if (mShowLastDivider && childCount > 0) {
+ View child = parent.getChildAt(childCount - 1);
+ RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
+ if (orientation == LinearLayoutManager.VERTICAL) {
+ top = child.getBottom() + params.bottomMargin;
+ bottom = top + size;
+ } else { // horizontal
+ left = child.getRight() + params.rightMargin;
+ right = left + size;
+ }
+ mDivider.setBounds(left, top, right, bottom);
+ mDivider.draw(c);
+ }
+ }
+
+ private int getOrientation(RecyclerView parent) {
+ if (parent.getLayoutManager() instanceof LinearLayoutManager) {
+ LinearLayoutManager layoutManager = (LinearLayoutManager) parent.getLayoutManager();
+ return layoutManager.getOrientation();
+ } else {
+ throw new IllegalStateException(
+ "DividerItemDecoration can only be used with a LinearLayoutManager.");
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java
new file mode 100644
index 0000000000..d07fe30d89
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java
@@ -0,0 +1,37 @@
+package org.citra.citra_emu.ui;
+
+import android.view.View;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.NonNull;
+import androidx.slidingpanelayout.widget.SlidingPaneLayout;
+
+public class TwoPaneOnBackPressedCallback extends OnBackPressedCallback
+ implements SlidingPaneLayout.PanelSlideListener {
+ private final SlidingPaneLayout mSlidingPaneLayout;
+
+ public TwoPaneOnBackPressedCallback(@NonNull SlidingPaneLayout slidingPaneLayout) {
+ super(slidingPaneLayout.isSlideable() && slidingPaneLayout.isOpen());
+ mSlidingPaneLayout = slidingPaneLayout;
+ slidingPaneLayout.addPanelSlideListener(this);
+ }
+
+ @Override
+ public void handleOnBackPressed() {
+ mSlidingPaneLayout.close();
+ }
+
+ @Override
+ public void onPanelSlide(@NonNull View panel, float slideOffset) {
+ }
+
+ @Override
+ public void onPanelOpened(@NonNull View panel) {
+ setEnabled(true);
+ }
+
+ @Override
+ public void onPanelClosed(@NonNull View panel) {
+ setEnabled(false);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
new file mode 100644
index 0000000000..4ba419a489
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java
@@ -0,0 +1,267 @@
+package org.citra.citra_emu.ui.main;
+
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.Toolbar;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+import org.citra.citra_emu.features.settings.ui.SettingsActivity;
+import org.citra.citra_emu.model.GameProvider;
+import org.citra.citra_emu.ui.platform.PlatformGamesFragment;
+import org.citra.citra_emu.utils.AddDirectoryHelper;
+import org.citra.citra_emu.utils.BillingManager;
+import org.citra.citra_emu.utils.DirectoryInitialization;
+import org.citra.citra_emu.utils.FileBrowserHelper;
+import org.citra.citra_emu.utils.PermissionsHandler;
+import org.citra.citra_emu.utils.PicassoUtils;
+import org.citra.citra_emu.utils.StartupHandler;
+import org.citra.citra_emu.utils.ThemeUtil;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+/**
+ * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which
+ * individually display a grid of available games for each Fragment, in a tabbed layout.
+ */
+public final class MainActivity extends AppCompatActivity implements MainView {
+ private Toolbar mToolbar;
+ private int mFrameLayoutId;
+ private PlatformGamesFragment mPlatformGamesFragment;
+
+ private MainPresenter mPresenter = new MainPresenter(this);
+
+ // Singleton to manage user billing state
+ private static BillingManager mBillingManager;
+
+ private static MenuItem mPremiumButton;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ ThemeUtil.applyTheme();
+
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ findViews();
+
+ setSupportActionBar(mToolbar);
+
+ mFrameLayoutId = R.id.games_platform_frame;
+ mPresenter.onCreate();
+
+ if (savedInstanceState == null) {
+ StartupHandler.HandleInit(this);
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ mPlatformGamesFragment = new PlatformGamesFragment();
+ getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
+ .commit();
+ }
+ } else {
+ mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment");
+ }
+ PicassoUtils.init();
+
+ // Setup billing manager, so we can globally query for Premium status
+ mBillingManager = new BillingManager(this);
+
+ // Dismiss previous notifications (should not happen unless a crash occurred)
+ EmulationActivity.tryDismissRunningNotification(this);
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ if (getSupportFragmentManager() == null) {
+ return;
+ }
+ if (outState == null) {
+ return;
+ }
+ getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment);
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ mPresenter.addDirIfNeeded(new AddDirectoryHelper(this));
+ }
+
+ // TODO: Replace with a ButterKnife injection.
+ private void findViews() {
+ mToolbar = findViewById(R.id.toolbar_main);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ MenuInflater inflater = getMenuInflater();
+ inflater.inflate(R.menu.menu_game_grid, menu);
+ mPremiumButton = menu.findItem(R.id.button_premium);
+
+ if (mBillingManager.isPremiumCached()) {
+ // User had premium in a previous session, hide upsell option
+ setPremiumButtonVisible(false);
+ }
+
+ return true;
+ }
+
+ static public void setPremiumButtonVisible(boolean isVisible) {
+ if (mPremiumButton != null) {
+ mPremiumButton.setVisible(isVisible);
+ }
+ }
+
+ /**
+ * MainView
+ */
+
+ @Override
+ public void setVersionString(String version) {
+ mToolbar.setSubtitle(version);
+ }
+
+ @Override
+ public void refresh() {
+ getContentResolver().insert(GameProvider.URI_REFRESH, null);
+ refreshFragment();
+ }
+
+ @Override
+ public void launchSettingsActivity(String menuTag) {
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ SettingsActivity.launch(this, menuTag, "");
+ } else {
+ PermissionsHandler.checkWritePermission(this);
+ }
+ }
+
+ @Override
+ public void launchFileListActivity(int request) {
+ if (PermissionsHandler.hasWriteAccess(this)) {
+ switch (request) {
+ case MainPresenter.REQUEST_ADD_DIRECTORY:
+ FileBrowserHelper.openDirectoryPicker(this,
+ MainPresenter.REQUEST_ADD_DIRECTORY,
+ R.string.select_game_folder,
+ Arrays.asList("xci", "nsp", "cci", "3ds",
+ "cxi", "app", "3dsx", "cia",
+ "rar", "zip", "7z", "torrent",
+ "tar", "gz", "nro"));
+ break;
+ case MainPresenter.REQUEST_INSTALL_CIA:
+ FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA,
+ R.string.install_cia_title,
+ Collections.singletonList("cia"), true);
+ break;
+ }
+ } else {
+ PermissionsHandler.checkWritePermission(this);
+ }
+ }
+
+ /**
+ * @param requestCode An int describing whether the Activity that is returning did so successfully.
+ * @param resultCode An int describing what Activity is giving us this callback.
+ * @param result The information the returning Activity is providing us.
+ */
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent result) {
+ super.onActivityResult(requestCode, resultCode, result);
+ switch (requestCode) {
+ case MainPresenter.REQUEST_ADD_DIRECTORY:
+ // If the user picked a file, as opposed to just backing out.
+ if (resultCode == MainActivity.RESULT_OK) {
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ // TODO(bunnei): Consider fixing this in the future, or removing code for this.
+ getContentResolver().insert(GameProvider.URI_RESET, null);
+ // Add the new directory
+ mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result));
+ }
+ break;
+ case MainPresenter.REQUEST_INSTALL_CIA:
+ // If the user picked a file, as opposed to just backing out.
+ if (resultCode == MainActivity.RESULT_OK) {
+ mPresenter.refeshGameList();
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ switch (requestCode) {
+ case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION:
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ DirectoryInitialization.start(this);
+
+ mPlatformGamesFragment = new PlatformGamesFragment();
+ getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment)
+ .commit();
+
+ // Immediately prompt user to select a game directory on first boot
+ if (mPresenter != null) {
+ mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY);
+ }
+ } else {
+ Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT)
+ .show();
+ }
+ break;
+ default:
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ break;
+ }
+ }
+
+ /**
+ * Called by the framework whenever any actionbar/toolbar icon is clicked.
+ *
+ * @param item The icon that was clicked on.
+ * @return True if the event was handled, false to bubble it up to the OS.
+ */
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ return mPresenter.handleOptionSelection(item.getItemId());
+ }
+
+ private void refreshFragment() {
+ if (mPlatformGamesFragment != null) {
+ mPlatformGamesFragment.refresh();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ EmulationActivity.tryDismissRunningNotification(this);
+ super.onDestroy();
+ }
+
+ /**
+ * @return true if Premium subscription is currently active
+ */
+ public static boolean isPremiumActive() {
+ return mBillingManager.isPremiumActive();
+ }
+
+ /**
+ * Invokes the billing flow for Premium
+ *
+ * @param callback Optional callback, called once, on completion of billing
+ */
+ public static void invokePremiumBilling(Runnable callback) {
+ mBillingManager.invokePremiumBilling(callback);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
new file mode 100644
index 0000000000..4e9994c2a4
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java
@@ -0,0 +1,82 @@
+package org.citra.citra_emu.ui.main;
+
+import android.os.SystemClock;
+
+import org.citra.citra_emu.BuildConfig;
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.model.Settings;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.AddDirectoryHelper;
+
+public final class MainPresenter {
+ public static final int REQUEST_ADD_DIRECTORY = 1;
+ public static final int REQUEST_INSTALL_CIA = 2;
+
+ private final MainView mView;
+ private String mDirToAdd;
+ private long mLastClickTime = 0;
+
+ public MainPresenter(MainView view) {
+ mView = view;
+ }
+
+ public void onCreate() {
+ String versionName = BuildConfig.VERSION_NAME;
+ mView.setVersionString(versionName);
+ refeshGameList();
+ }
+
+ public void launchFileListActivity(int request) {
+ if (mView != null) {
+ mView.launchFileListActivity(request);
+ }
+ }
+
+ public boolean handleOptionSelection(int itemId) {
+ // Double-click prevention, using threshold of 500 ms
+ if (SystemClock.elapsedRealtime() - mLastClickTime < 500) {
+ return false;
+ }
+ mLastClickTime = SystemClock.elapsedRealtime();
+
+ switch (itemId) {
+ case R.id.menu_settings_core:
+ mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG);
+ return true;
+
+ case R.id.button_add_directory:
+ launchFileListActivity(REQUEST_ADD_DIRECTORY);
+ return true;
+
+ case R.id.button_install_cia:
+ launchFileListActivity(REQUEST_INSTALL_CIA);
+ return true;
+
+ case R.id.button_premium:
+ mView.launchSettingsActivity(Settings.SECTION_PREMIUM);
+ return true;
+ }
+
+ return false;
+ }
+
+ public void addDirIfNeeded(AddDirectoryHelper helper) {
+ if (mDirToAdd != null) {
+ helper.addDirectory(mDirToAdd, mView::refresh);
+
+ mDirToAdd = null;
+ }
+ }
+
+ public void onDirectorySelected(String dir) {
+ mDirToAdd = dir;
+ }
+
+ public void refeshGameList() {
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+ databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
+ mView.refresh();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
new file mode 100644
index 0000000000..de7c048759
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java
@@ -0,0 +1,25 @@
+package org.citra.citra_emu.ui.main;
+
+/**
+ * Abstraction for the screen that shows on application launch.
+ * Implementations will differ primarily to target touch-screen
+ * or non-touch screen devices.
+ */
+public interface MainView {
+ /**
+ * Pass the view the native library's version string. Displaying
+ * it is optional.
+ *
+ * @param version A string pulled from native code.
+ */
+ void setVersionString(String version);
+
+ /**
+ * Tell the view to refresh its contents.
+ */
+ void refresh();
+
+ void launchSettingsActivity(String menuTag);
+
+ void launchFileListActivity(int request);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
new file mode 100644
index 0000000000..9fc30796f1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java
@@ -0,0 +1,86 @@
+package org.citra.citra_emu.ui.platform;
+
+import android.database.Cursor;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.GridLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.adapters.GameAdapter;
+import org.citra.citra_emu.model.GameDatabase;
+
+public final class PlatformGamesFragment extends Fragment implements PlatformGamesView {
+ private PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this);
+
+ private GameAdapter mAdapter;
+ private RecyclerView mRecyclerView;
+ private TextView mTextView;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ View rootView = inflater.inflate(R.layout.fragment_grid, container, false);
+
+ findViews(rootView);
+
+ mPresenter.onCreateView();
+
+ return rootView;
+ }
+
+ @Override
+ public void onViewCreated(View view, Bundle savedInstanceState) {
+ int columns = getResources().getInteger(R.integer.game_grid_columns);
+ RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns);
+ mAdapter = new GameAdapter();
+
+ mRecyclerView.setLayoutManager(layoutManager);
+ mRecyclerView.setAdapter(mAdapter);
+ mRecyclerView.addItemDecoration(new GameAdapter.SpacesItemDecoration(ContextCompat.getDrawable(getActivity(), R.drawable.gamelist_divider), 1));
+
+ // Add swipe down to refresh gesture
+ final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.refresh_grid_games);
+ pullToRefresh.setOnRefreshListener(() -> {
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+ databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
+ refresh();
+ pullToRefresh.setRefreshing(false);
+ });
+ }
+
+ @Override
+ public void refresh() {
+ mPresenter.refresh();
+ updateTextView();
+ }
+
+ @Override
+ public void showGames(Cursor games) {
+ if (mAdapter != null) {
+ mAdapter.swapCursor(games);
+ }
+ updateTextView();
+ }
+
+ private void updateTextView() {
+ mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE);
+ }
+
+ private void findViews(View root) {
+ mRecyclerView = root.findViewById(R.id.grid_games);
+ mTextView = root.findViewById(R.id.gamelist_empty_text);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
new file mode 100644
index 0000000000..9d8040e1be
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java
@@ -0,0 +1,42 @@
+package org.citra.citra_emu.ui.platform;
+
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.utils.Log;
+
+import rx.android.schedulers.AndroidSchedulers;
+import rx.schedulers.Schedulers;
+
+public final class PlatformGamesPresenter {
+ private final PlatformGamesView mView;
+
+ public PlatformGamesPresenter(PlatformGamesView view) {
+ mView = view;
+ }
+
+ public void onCreateView() {
+ loadGames();
+ }
+
+ public void refresh() {
+ Log.debug("[PlatformGamesPresenter] : Refreshing...");
+ loadGames();
+ }
+
+ private void loadGames() {
+ Log.debug("[PlatformGamesPresenter] : Loading games...");
+
+ GameDatabase databaseHelper = CitraApplication.databaseHelper;
+
+ databaseHelper.getGames()
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(games ->
+ {
+ Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor...");
+
+ mView.showGames(games);
+ });
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
new file mode 100644
index 0000000000..4332121eb8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java
@@ -0,0 +1,21 @@
+package org.citra.citra_emu.ui.platform;
+
+import android.database.Cursor;
+
+/**
+ * Abstraction for a screen representing a single platform's games.
+ */
+public interface PlatformGamesView {
+ /**
+ * Tell the view to refresh its contents.
+ */
+ void refresh();
+
+ /**
+ * To be called when an asynchronous database read completes. Passes the
+ * result, in this case a {@link Cursor}, to the view.
+ *
+ * @param games A Cursor containing the games read from the database.
+ */
+ void showGames(Cursor games);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java
new file mode 100644
index 0000000000..886846ec57
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java
@@ -0,0 +1,5 @@
+package org.citra.citra_emu.utils;
+
+public interface Action1 {
+ void call(T t);
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
new file mode 100644
index 0000000000..7578c353f8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java
@@ -0,0 +1,38 @@
+package org.citra.citra_emu.utils;
+
+import android.content.AsyncQueryHandler;
+import android.content.ContentValues;
+import android.content.Context;
+import android.net.Uri;
+
+import org.citra.citra_emu.model.GameDatabase;
+import org.citra.citra_emu.model.GameProvider;
+
+public class AddDirectoryHelper {
+ private Context mContext;
+
+ public AddDirectoryHelper(Context context) {
+ this.mContext = context;
+ }
+
+ public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) {
+ AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) {
+ @Override
+ protected void onInsertComplete(int token, Object cookie, Uri uri) {
+ addDirectoryListener.onDirectoryAdded();
+ }
+ };
+
+ ContentValues file = new ContentValues();
+ file.put(GameDatabase.KEY_FOLDER_PATH, dir);
+
+ handler.startInsert(0, // We don't need to identify this call to the handler
+ null, // We don't need to pass additional data to the handler
+ GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder
+ file);
+ }
+
+ public interface AddDirectoryListener {
+ void onDirectoryAdded();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java
new file mode 100644
index 0000000000..dfbab17804
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java
@@ -0,0 +1,22 @@
+package org.citra.citra_emu.utils;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class BiMap {
+ private Map forward = new HashMap();
+ private Map backward = new HashMap();
+
+ public synchronized void add(K key, V value) {
+ forward.put(key, value);
+ backward.put(value, key);
+ }
+
+ public synchronized V getForward(K key) {
+ return forward.get(key);
+ }
+
+ public synchronized K getBackward(V key) {
+ return backward.get(key);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
new file mode 100644
index 0000000000..5dc54c235e
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java
@@ -0,0 +1,215 @@
+package org.citra.citra_emu.utils;
+
+import android.app.Activity;
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+import android.widget.Toast;
+
+import com.android.billingclient.api.AcknowledgePurchaseParams;
+import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.Purchase.PurchasesResult;
+import com.android.billingclient.api.PurchasesUpdatedListener;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+import org.citra.citra_emu.ui.main.MainActivity;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class BillingManager implements PurchasesUpdatedListener {
+ private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium";
+
+ private final Activity mActivity;
+ private BillingClient mBillingClient;
+ private SkuDetails mSkuPremium;
+ private boolean mIsPremiumActive = false;
+ private boolean mIsServiceConnected = false;
+ private Runnable mUpdateBillingCallback;
+
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ public BillingManager(Activity activity) {
+ mActivity = activity;
+ mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build();
+ querySkuDetails();
+ }
+
+ static public boolean isPremiumCached() {
+ return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false);
+ }
+
+ /**
+ * @return true if Premium subscription is currently active
+ */
+ public boolean isPremiumActive() {
+ return mIsPremiumActive;
+ }
+
+ /**
+ * Invokes the billing flow for Premium
+ *
+ * @param callback Optional callback, called once, on completion of billing
+ */
+ public void invokePremiumBilling(Runnable callback) {
+ if (mSkuPremium == null) {
+ return;
+ }
+
+ // Optional callback to refresh the UI for the caller when billing completes
+ mUpdateBillingCallback = callback;
+
+ // Invoke the billing flow
+ BillingFlowParams flowParams = BillingFlowParams.newBuilder()
+ .setSkuDetails(mSkuPremium)
+ .build();
+ mBillingClient.launchBillingFlow(mActivity, flowParams);
+ }
+
+ private void updatePremiumState(boolean isPremiumActive) {
+ mIsPremiumActive = isPremiumActive;
+
+ // Cache state for synchronous UI
+ SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive);
+ editor.apply();
+
+ // No need to show button in action bar if Premium is active
+ MainActivity.setPremiumButtonVisible(!isPremiumActive);
+ }
+
+ @Override
+ public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) {
+ if (purchaseList == null || purchaseList.isEmpty()) {
+ // Premium is not active, or billing is unavailable
+ updatePremiumState(false);
+ return;
+ }
+
+ Purchase premiumPurchase = null;
+ for (Purchase purchase : purchaseList) {
+ if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) {
+ premiumPurchase = purchase;
+ }
+ }
+
+ if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) {
+ // Premium has been purchased
+ updatePremiumState(true);
+
+ // Acknowledge the purchase if it hasn't already been acknowledged.
+ if (!premiumPurchase.isAcknowledged()) {
+ AcknowledgePurchaseParams acknowledgePurchaseParams =
+ AcknowledgePurchaseParams.newBuilder()
+ .setPurchaseToken(premiumPurchase.getPurchaseToken())
+ .build();
+
+ AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> {
+ Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show();
+ };
+ mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener);
+ }
+
+ if (mUpdateBillingCallback != null) {
+ try {
+ mUpdateBillingCallback.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ mUpdateBillingCallback = null;
+ }
+ }
+ }
+
+ private void onQuerySkuDetailsFinished(List skuDetailsList) {
+ if (skuDetailsList == null) {
+ // This can happen when no user is signed in
+ return;
+ }
+
+ if (skuDetailsList.isEmpty()) {
+ return;
+ }
+
+ mSkuPremium = skuDetailsList.get(0);
+
+ queryPurchases();
+ }
+
+ private void querySkuDetails() {
+ Runnable queryToExecute = new Runnable() {
+ @Override
+ public void run() {
+ SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
+ List skuList = new ArrayList<>();
+
+ skuList.add(BILLING_SKU_PREMIUM);
+ params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP);
+
+ mBillingClient.querySkuDetailsAsync(params.build(),
+ (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList));
+ }
+ };
+
+ executeServiceRequest(queryToExecute);
+ }
+
+ private void onQueryPurchasesFinished(PurchasesResult result) {
+ // Have we been disposed of in the meantime? If so, or bad result code, then quit
+ if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) {
+ updatePremiumState(false);
+ return;
+ }
+ // Update the UI and purchases inventory with new list of purchases
+ onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList());
+ }
+
+ private void queryPurchases() {
+ Runnable queryToExecute = new Runnable() {
+ @Override
+ public void run() {
+ final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP);
+ onQueryPurchasesFinished(purchasesResult);
+ }
+ };
+
+ executeServiceRequest(queryToExecute);
+ }
+
+ private void startServiceConnection(final Runnable executeOnFinish) {
+ mBillingClient.startConnection(new BillingClientStateListener() {
+ @Override
+ public void onBillingSetupFinished(BillingResult billingResult) {
+ if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+ mIsServiceConnected = true;
+ }
+
+ if (executeOnFinish != null) {
+ executeOnFinish.run();
+ }
+ }
+
+ @Override
+ public void onBillingServiceDisconnected() {
+ mIsServiceConnected = false;
+ }
+ });
+ }
+
+ private void executeServiceRequest(Runnable runnable) {
+ if (mIsServiceConnected) {
+ runnable.run();
+ } else {
+ // If billing service was disconnected, we try to reconnect 1 time.
+ startServiceConnection(runnable);
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java
new file mode 100644
index 0000000000..f801a05f0f
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java
@@ -0,0 +1,66 @@
+package org.citra.citra_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.
+ */
+public class ControllerMappingHelper {
+ /**
+ * Some controllers report extra button presses that can be ignored.
+ */
+ public boolean shouldKeyBeIgnored(InputDevice inputDevice, int keyCode) {
+ 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
+ return keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2;
+ }
+ return false;
+ }
+
+ /**
+ * Scale an axis to be zero-centered with a proper range.
+ */
+ public float scaleAxis(InputDevice inputDevice, int axis, float value) {
+ 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;
+ }
+
+ private boolean isDualShock4(InputDevice inputDevice) {
+ // Sony DualShock 4 controller
+ return inputDevice.getVendorId() == 0x54c && inputDevice.getProductId() == 0x9cc;
+ }
+
+ private boolean isXboxOneWireless(InputDevice inputDevice) {
+ // Microsoft Xbox One controller
+ return inputDevice.getVendorId() == 0x45e && inputDevice.getProductId() == 0x2e0;
+ }
+
+ private boolean isMogaPro2Hid(InputDevice inputDevice) {
+ // Moga Pro 2 HID
+ return inputDevice.getVendorId() == 0x20d6 && inputDevice.getProductId() == 0x6271;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
new file mode 100644
index 0000000000..58e552f5eb
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java
@@ -0,0 +1,186 @@
+/**
+ * Copyright 2014 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_emu.utils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Environment;
+import android.preference.PreferenceManager;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.citra.citra_emu.NativeLibrary;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A service that spawns its own thread in order to copy several binary and shader files
+ * from the Citra APK to the external file system.
+ */
+public final class DirectoryInitialization {
+ public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST";
+
+ public static final String EXTRA_STATE = "directoryState";
+ private static volatile DirectoryInitializationState directoryState = null;
+ private static String userPath;
+ private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false);
+
+ public static void start(Context context) {
+ // Can take a few seconds to run, so don't block UI thread.
+ //noinspection TrivialFunctionalExpressionUsage
+ ((Runnable) () -> init(context)).run();
+ }
+
+ private static void init(Context context) {
+ if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true))
+ return;
+
+ if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) {
+ if (PermissionsHandler.hasWriteAccess(context)) {
+ if (setCitraUserDirectory()) {
+ initializeInternalStorage(context);
+ NativeLibrary.CreateConfigFile();
+ directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
+ } else {
+ directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE;
+ }
+ } else {
+ directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED;
+ }
+ }
+
+ isCitraDirectoryInitializationRunning.set(false);
+ sendBroadcastState(directoryState, context);
+ }
+
+ private static void deleteDirectoryRecursively(File file) {
+ if (file.isDirectory()) {
+ for (File child : file.listFiles())
+ deleteDirectoryRecursively(child);
+ }
+ file.delete();
+ }
+
+ public static boolean areCitraDirectoriesReady() {
+ return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED;
+ }
+
+ public static String getUserDirectory() {
+ if (directoryState == null) {
+ throw new IllegalStateException("DirectoryInitialization has to run at least once!");
+ } else if (isCitraDirectoryInitializationRunning.get()) {
+ throw new IllegalStateException(
+ "DirectoryInitialization has to finish running first!");
+ }
+ return userPath;
+ }
+
+ private static native void SetSysDirectory(String path);
+
+ private static boolean setCitraUserDirectory() {
+ if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
+ File externalPath = Environment.getExternalStorageDirectory();
+ if (externalPath != null) {
+ userPath = externalPath.getAbsolutePath() + "/citra-emu";
+ Log.debug("[DirectoryInitialization] User Dir: " + userPath);
+ // NativeLibrary.SetUserDirectory(userPath);
+ return true;
+ }
+
+ }
+
+ return false;
+ }
+
+ private static void initializeInternalStorage(Context context) {
+ File sysDirectory = new File(context.getFilesDir(), "Sys");
+
+ SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context);
+ String revision = NativeLibrary.GetGitRevision();
+ if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) {
+ // There is no extracted Sys directory, or there is a Sys directory from another
+ // version of Citra that might contain outdated files. Let's (re-)extract Sys.
+ deleteDirectoryRecursively(sysDirectory);
+ copyAssetFolder("Sys", sysDirectory, true, context);
+
+ SharedPreferences.Editor editor = preferences.edit();
+ editor.putString("sysDirectoryVersion", revision);
+ editor.apply();
+ }
+
+ // Let the native code know where the Sys directory is.
+ SetSysDirectory(sysDirectory.getPath());
+ }
+
+ private static void sendBroadcastState(DirectoryInitializationState state, Context context) {
+ Intent localIntent =
+ new Intent(BROADCAST_ACTION)
+ .putExtra(EXTRA_STATE, state);
+ LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent);
+ }
+
+ private static void copyAsset(String asset, File output, Boolean overwrite, Context context) {
+ Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output);
+
+ try {
+ if (!output.exists() || overwrite) {
+ InputStream in = context.getAssets().open(asset);
+ OutputStream out = new FileOutputStream(output);
+ copyFile(in, out);
+ in.close();
+ out.close();
+ }
+ } catch (IOException e) {
+ Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset +
+ e.getMessage());
+ }
+ }
+
+ private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite,
+ Context context) {
+ Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " +
+ outputFolder);
+
+ try {
+ boolean createdFolder = false;
+ for (String file : context.getAssets().list(assetFolder)) {
+ if (!createdFolder) {
+ outputFolder.mkdir();
+ createdFolder = true;
+ }
+ copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file),
+ overwrite, context);
+ copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite,
+ context);
+ }
+ } catch (IOException e) {
+ Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder +
+ e.getMessage());
+ }
+ }
+
+ private static void copyFile(InputStream in, OutputStream out) throws IOException {
+ byte[] buffer = new byte[1024];
+ int read;
+
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ }
+
+ public enum DirectoryInitializationState {
+ CITRA_DIRECTORIES_INITIALIZED,
+ EXTERNAL_STORAGE_PERMISSION_NEEDED,
+ CANT_FIND_EXTERNAL_STORAGE
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
new file mode 100644
index 0000000000..5d1e951ca1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java
@@ -0,0 +1,22 @@
+package org.citra.citra_emu.utils;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState;
+
+public class DirectoryStateReceiver extends BroadcastReceiver {
+ Action1 callback;
+
+ public DirectoryStateReceiver(Action1 callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ DirectoryInitializationState state = (DirectoryInitializationState) intent
+ .getSerializableExtra(DirectoryInitialization.EXTRA_STATE);
+ callback.call(state);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
new file mode 100644
index 0000000000..9664f84647
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java
@@ -0,0 +1,78 @@
+package org.citra.citra_emu.utils;
+
+import android.content.SharedPreferences;
+import android.preference.PreferenceManager;
+
+import org.citra.citra_emu.CitraApplication;
+
+public class EmulationMenuSettings {
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ // These must match what is defined in src/core/settings.h
+ public static final int LayoutOption_Default = 0;
+ public static final int LayoutOption_SingleScreen = 1;
+ public static final int LayoutOption_LargeScreen = 2;
+ public static final int LayoutOption_SideScreen = 3;
+ public static final int LayoutOption_MobilePortrait = 4;
+ public static final int LayoutOption_MobileLandscape = 5;
+
+ public static boolean getJoystickRelCenter() {
+ return mPreferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true);
+ }
+
+ public static void setJoystickRelCenter(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_JoystickRelCenter", value);
+ editor.apply();
+ }
+
+ public static boolean getDpadSlideEnable() {
+ return mPreferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true);
+ }
+
+ public static void setDpadSlideEnable(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_DpadSlideEnable", value);
+ editor.apply();
+ }
+
+ public static int getLandscapeScreenLayout() {
+ return mPreferences.getInt("EmulationMenuSettings_LandscapeScreenLayout", LayoutOption_MobileLandscape);
+ }
+
+ public static void setLandscapeScreenLayout(int value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putInt("EmulationMenuSettings_LandscapeScreenLayout", value);
+ editor.apply();
+ }
+
+ public static boolean getShowFps() {
+ return mPreferences.getBoolean("EmulationMenuSettings_ShowFps", false);
+ }
+
+ public static void setShowFps(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_ShowFps", value);
+ editor.apply();
+ }
+
+ public static boolean getSwapScreens() {
+ return mPreferences.getBoolean("EmulationMenuSettings_SwapScreens", false);
+ }
+
+ public static void setSwapScreens(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_SwapScreens", value);
+ editor.apply();
+ }
+
+ public static boolean getShowOverlay() {
+ return mPreferences.getBoolean("EmulationMenuSettings_ShowOverylay", true);
+ }
+
+ public static void setShowOverlay(boolean value) {
+ final SharedPreferences.Editor editor = mPreferences.edit();
+ editor.putBoolean("EmulationMenuSettings_ShowOverylay", value);
+ editor.apply();
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
new file mode 100644
index 0000000000..baf691f5c1
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java
@@ -0,0 +1,73 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Environment;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.FragmentActivity;
+
+import com.nononsenseapps.filepicker.FilePickerActivity;
+import com.nononsenseapps.filepicker.Utils;
+
+import org.citra.citra_emu.activities.CustomFilePickerActivity;
+
+import java.io.File;
+import java.util.List;
+
+public final class FileBrowserHelper {
+ public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) {
+ Intent i = new Intent(activity, CustomFilePickerActivity.class);
+
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false);
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
+ i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR);
+ i.putExtra(FilePickerActivity.EXTRA_START_PATH,
+ Environment.getExternalStorageDirectory().getPath());
+ i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
+ i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
+
+ activity.startActivityForResult(i, requestCode);
+ }
+
+ public static void openFilePicker(FragmentActivity activity, int requestCode, int title,
+ List extensions, boolean allowMultiple) {
+ Intent i = new Intent(activity, CustomFilePickerActivity.class);
+
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple);
+ i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false);
+ i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE);
+ i.putExtra(FilePickerActivity.EXTRA_START_PATH,
+ Environment.getExternalStorageDirectory().getPath());
+ i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title);
+ i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions));
+
+ activity.startActivityForResult(i, requestCode);
+ }
+
+ @Nullable
+ public static String getSelectedDirectory(Intent result) {
+ // Use the provided utility method to parse the result
+ List files = Utils.getSelectedFilesFromResult(result);
+ if (!files.isEmpty()) {
+ File file = Utils.getFileForUri(files.get(0));
+ return file.getAbsolutePath();
+ }
+
+ return null;
+ }
+
+ @Nullable
+ public static String[] getSelectedFiles(Intent result) {
+ // Use the provided utility method to parse the result
+ List files = Utils.getSelectedFilesFromResult(result);
+ if (!files.isEmpty()) {
+ String[] paths = new String[files.size()];
+ for (int i = 0; i < files.size(); i++)
+ paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath();
+ return paths;
+ }
+
+ return null;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
new file mode 100644
index 0000000000..f9025171b7
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java
@@ -0,0 +1,37 @@
+package org.citra.citra_emu.utils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class FileUtil {
+ public static byte[] getBytesFromFile(File file) throws IOException {
+ final long length = file.length();
+
+ // You cannot create an array using a long type.
+ if (length > Integer.MAX_VALUE) {
+ // File is too large
+ throw new IOException("File is too large!");
+ }
+
+ byte[] bytes = new byte[(int) length];
+
+ int offset = 0;
+ int numRead;
+
+ try (InputStream is = new FileInputStream(file)) {
+ while (offset < bytes.length
+ && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
+ offset += numRead;
+ }
+ }
+
+ // Ensure all the bytes have been read in
+ if (offset < bytes.length) {
+ throw new IOException("Could not completely read file " + file.getName());
+ }
+
+ return bytes;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
new file mode 100644
index 0000000000..31c4157793
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright 2014 Dolphin Emulator Project
+ * Licensed under GPLv2+
+ * Refer to the license.txt file included.
+ */
+
+package org.citra.citra_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.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+
+/**
+ * A service that shows a permanent notification in the background to avoid the app getting
+ * cleared from memory by the system.
+ */
+public class ForegroundService extends Service {
+ private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000;
+
+ private void showRunningNotification() {
+ // Intent is used to resume emulation if the notification is clicked
+ PendingIntent contentIntent = PendingIntent.getActivity(this, 0,
+ new Intent(this, EmulationActivity.class), PendingIntent.FLAG_IMMUTABLE);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id))
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.app_notification_running))
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setVibrate(null)
+ .setSound(null)
+ .setContentIntent(contentIntent);
+ startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build());
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ showRunningNotification();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ return START_STICKY;
+ }
+
+ @Override
+ public void onDestroy() {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
new file mode 100644
index 0000000000..b790c24801
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java
@@ -0,0 +1,27 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+
+import com.squareup.picasso.Picasso;
+import com.squareup.picasso.Request;
+import com.squareup.picasso.RequestHandler;
+
+import org.citra.citra_emu.NativeLibrary;
+
+import java.nio.IntBuffer;
+
+public class GameIconRequestHandler extends RequestHandler {
+ @Override
+ public boolean canHandleRequest(Request data) {
+ return "iso".equals(data.uri.getScheme());
+ }
+
+ @Override
+ public Result load(Request request, int networkPolicy) {
+ String url = request.uri.getHost() + request.uri.getPath();
+ int[] vector = NativeLibrary.GetIcon(url);
+ Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565);
+ bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector));
+ return new Result(bitmap, Picasso.LoadedFrom.DISK);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
new file mode 100644
index 0000000000..070d01eb1a
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java
@@ -0,0 +1,39 @@
+package org.citra.citra_emu.utils;
+
+import org.citra.citra_emu.BuildConfig;
+
+/**
+ * Contains methods that call through to {@link android.util.Log}, but
+ * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log
+ * levels in release builds.
+ */
+public final class Log {
+ private static final String TAG = "Citra Frontend";
+
+ private Log() {
+ }
+
+ public static void verbose(String message) {
+ if (BuildConfig.DEBUG) {
+ android.util.Log.v(TAG, message);
+ }
+ }
+
+ public static void debug(String message) {
+ if (BuildConfig.DEBUG) {
+ android.util.Log.d(TAG, message);
+ }
+ }
+
+ public static void info(String message) {
+ android.util.Log.i(TAG, message);
+ }
+
+ public static void warning(String message) {
+ android.util.Log.w(TAG, message);
+ }
+
+ public static void error(String message) {
+ android.util.Log.e(TAG, message);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
new file mode 100644
index 0000000000..a29e23e8d9
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java
@@ -0,0 +1,35 @@
+package org.citra.citra_emu.utils;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
+
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.FragmentActivity;
+
+import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE;
+
+public class PermissionsHandler {
+ public static final int REQUEST_CODE_WRITE_PERMISSION = 500;
+
+ // We use permissions acceptance as an indicator if this is a first boot for the user.
+ public static boolean isFirstBoot(final FragmentActivity activity) {
+ return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED;
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ public static boolean checkWritePermission(final FragmentActivity activity) {
+ if (isFirstBoot(activity)) {
+ activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE},
+ REQUEST_CODE_WRITE_PERMISSION);
+ return false;
+ }
+
+ return true;
+ }
+
+ public static boolean hasWriteAccess(Context context) {
+ return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
new file mode 100644
index 0000000000..892b463877
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+
+import com.squareup.picasso.Transformation;
+
+public class PicassoRoundedCornersTransformation implements Transformation {
+ @Override
+ public Bitmap transform(Bitmap icon) {
+ final int width = icon.getWidth();
+ final int height = icon.getHeight();
+ final Rect rect = new Rect(0, 0, width, height);
+ final int size = Math.min(width, height);
+ final int x = (width - size) / 2;
+ final int y = (height - size) / 2;
+
+ Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size);
+ if (squaredBitmap != icon) {
+ icon.recycle();
+ }
+
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+ BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP);
+ Paint paint = new Paint();
+ paint.setAntiAlias(true);
+ paint.setShader(shader);
+
+ canvas.drawRoundRect(new RectF(rect), 10, 10, paint);
+
+ squaredBitmap.recycle();
+
+ return output;
+ }
+
+ @Override
+ public String key() {
+ return "circle";
+ }
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
new file mode 100644
index 0000000000..c997266857
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java
@@ -0,0 +1,57 @@
+package org.citra.citra_emu.utils;
+
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.widget.ImageView;
+
+import com.squareup.picasso.Picasso;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.R;
+
+import java.io.IOException;
+
+import androidx.annotation.Nullable;
+
+public class PicassoUtils {
+ private static boolean mPicassoInitialized = false;
+
+ public static void init() {
+ if (mPicassoInitialized) {
+ return;
+ }
+ Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext())
+ .addRequestHandler(new GameIconRequestHandler())
+ .build();
+
+ Picasso.setSingletonInstance(picassoInstance);
+ mPicassoInitialized = true;
+ }
+
+ public static void loadGameIcon(ImageView imageView, String gamePath) {
+ Picasso
+ .get()
+ .load(Uri.parse("iso:/" + gamePath))
+ .fit()
+ .centerInside()
+ .config(Bitmap.Config.RGB_565)
+ .error(R.drawable.no_icon)
+ .transform(new PicassoRoundedCornersTransformation())
+ .into(imageView);
+ }
+
+ // Blocking call. Load image from file and crop/resize it to fit in width x height.
+ @Nullable
+ public static Bitmap LoadBitmapFromFile(String uri, int width, int height) {
+ try {
+ return Picasso.get()
+ .load(Uri.parse(uri))
+ .config(Bitmap.Config.ARGB_8888)
+ .centerCrop()
+ .resize(width, height)
+ .get();
+ } catch (IOException e) {
+ return null;
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
new file mode 100644
index 0000000000..9112bf90ce
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java
@@ -0,0 +1,45 @@
+package org.citra.citra_emu.utils;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.text.TextUtils;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.FragmentActivity;
+
+import org.citra.citra_emu.R;
+import org.citra.citra_emu.activities.EmulationActivity;
+
+public final class StartupHandler {
+ private static void handlePermissionsCheck(FragmentActivity parent) {
+ // Ask the user to grant write permission if it's not already granted
+ PermissionsHandler.checkWritePermission(parent);
+
+ String start_file = "";
+ Bundle extras = parent.getIntent().getExtras();
+ if (extras != null) {
+ start_file = extras.getString("AutoStartFile");
+ }
+
+ if (!TextUtils.isEmpty(start_file)) {
+ // Start the emulation activity, send the ISO passed in and finish the main activity
+ Intent emulation_intent = new Intent(parent, EmulationActivity.class);
+ emulation_intent.putExtra("SelectedGame", start_file);
+ parent.startActivity(emulation_intent);
+ parent.finish();
+ }
+ }
+
+ public static void HandleInit(FragmentActivity parent) {
+ if (PermissionsHandler.isFirstBoot(parent)) {
+ // Prompt user with standard first boot disclaimer
+ new AlertDialog.Builder(parent)
+ .setTitle(R.string.app_name)
+ .setIcon(R.mipmap.ic_launcher)
+ .setMessage(parent.getResources().getString(R.string.app_disclaimer))
+ .setPositiveButton(android.R.string.ok, null)
+ .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent))
+ .show();
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
new file mode 100644
index 0000000000..74ef3867f2
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java
@@ -0,0 +1,34 @@
+package org.citra.citra_emu.utils;
+
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.preference.PreferenceManager;
+
+import androidx.appcompat.app.AppCompatDelegate;
+
+import org.citra.citra_emu.CitraApplication;
+import org.citra.citra_emu.features.settings.utils.SettingsFile;
+
+public class ThemeUtil {
+ private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext());
+
+ private static void applyTheme(int designValue) {
+ switch (designValue) {
+ case 0:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
+ break;
+ case 1:
+ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
+ break;
+ case 2:
+ AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ?
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM :
+ AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
+ break;
+ }
+ }
+
+ public static void applyTheme() {
+ applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0));
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java
new file mode 100644
index 0000000000..50dbcbe183
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java
@@ -0,0 +1,46 @@
+package org.citra.citra_emu.viewholders;
+
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.citra.citra_emu.R;
+
+/**
+ * A simple class that stores references to views so that the GameAdapter doesn't need to
+ * keep calling findViewById(), which is expensive.
+ */
+public class GameViewHolder extends RecyclerView.ViewHolder {
+ private View itemView;
+ public ImageView imageIcon;
+ public TextView textGameTitle;
+ public TextView textCompany;
+ public TextView textFileName;
+
+ public String gameId;
+
+ // TODO Not need any of this stuff. Currently only the properties dialog needs it.
+ public String path;
+ public String title;
+ public String description;
+ public String regions;
+ public String company;
+
+ public GameViewHolder(View itemView) {
+ super(itemView);
+
+ this.itemView = itemView;
+ itemView.setTag(this);
+
+ imageIcon = itemView.findViewById(R.id.image_game_screen);
+ textGameTitle = itemView.findViewById(R.id.text_game_title);
+ textCompany = itemView.findViewById(R.id.text_company);
+ textFileName = itemView.findViewById(R.id.text_filename);
+ }
+
+ public View getItemView() {
+ return itemView;
+ }
+}
diff --git a/src/android/app/src/main/res/animator/settings_enter.xml b/src/android/app/src/main/res/animator/settings_enter.xml
new file mode 100644
index 0000000000..3c216a054c
--- /dev/null
+++ b/src/android/app/src/main/res/animator/settings_enter.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/animator/settings_exit.xml b/src/android/app/src/main/res/animator/settings_exit.xml
new file mode 100644
index 0000000000..a233b6757f
--- /dev/null
+++ b/src/android/app/src/main/res/animator/settings_exit.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/animator/settings_pop_enter.xml b/src/android/app/src/main/res/animator/settings_pop_enter.xml
new file mode 100644
index 0000000000..080bc27c49
--- /dev/null
+++ b/src/android/app/src/main/res/animator/settings_pop_enter.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/animator/setttings_pop_exit.xml b/src/android/app/src/main/res/animator/setttings_pop_exit.xml
new file mode 100644
index 0000000000..4fccbcca2d
--- /dev/null
+++ b/src/android/app/src/main/res/animator/setttings_pop_exit.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a.png b/src/android/app/src/main/res/drawable-hdpi/button_a.png
new file mode 100644
index 0000000000..f96a2061ef
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png
new file mode 100644
index 0000000000..785a258ee5
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b.png b/src/android/app/src/main/res/drawable-hdpi/button_b.png
new file mode 100644
index 0000000000..b15d2b549c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png
new file mode 100644
index 0000000000..b11d5fcee1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l.png b/src/android/app/src/main/res/drawable-hdpi/button_l.png
new file mode 100644
index 0000000000..e19469a7b8
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png
new file mode 100644
index 0000000000..280857f642
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r.png b/src/android/app/src/main/res/drawable-hdpi/button_r.png
new file mode 100644
index 0000000000..f72cdc1dcf
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png
new file mode 100644
index 0000000000..c47d342535
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select.png b/src/android/app/src/main/res/drawable-hdpi/button_select.png
new file mode 100644
index 0000000000..6961b88d2b
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png
new file mode 100644
index 0000000000..8ee4714199
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start.png b/src/android/app/src/main/res/drawable-hdpi/button_start.png
new file mode 100644
index 0000000000..72856cf47e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png
new file mode 100644
index 0000000000..f96cd33591
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x.png b/src/android/app/src/main/res/drawable-hdpi/button_x.png
new file mode 100644
index 0000000000..1a0fd19245
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png
new file mode 100644
index 0000000000..089cb3af19
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y.png b/src/android/app/src/main/res/drawable-hdpi/button_y.png
new file mode 100644
index 0000000000..bc22680c43
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png
new file mode 100644
index 0000000000..6e9e89ec9b
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl.png b/src/android/app/src/main/res/drawable-hdpi/button_zl.png
new file mode 100644
index 0000000000..dd5d4d5b32
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png
new file mode 100644
index 0000000000..8cd395f3b7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr.png b/src/android/app/src/main/res/drawable-hdpi/button_zr.png
new file mode 100644
index 0000000000..728fcf4d10
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png
new file mode 100644
index 0000000000..1218776102
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad.png b/src/android/app/src/main/res/drawable-hdpi/dpad.png
new file mode 100644
index 0000000000..921b3902d0
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png
new file mode 100644
index 0000000000..a8ffbb48ac
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png
new file mode 100644
index 0000000000..ceb994a6d9
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png
new file mode 100644
index 0000000000..8c00d8c341
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png
new file mode 100644
index 0000000000..90085252b2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png
new file mode 100644
index 0000000000..7dd45a4055
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png
new file mode 100644
index 0000000000..2e7837020c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png
new file mode 100644
index 0000000000..2282f1a3b9
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c.png b/src/android/app/src/main/res/drawable-hdpi/stick_c.png
new file mode 100644
index 0000000000..d4c1d6c976
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png
new file mode 100644
index 0000000000..c8d14c0292
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png
new file mode 100644
index 0000000000..8263d4b8db
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main.png b/src/android/app/src/main/res/drawable-hdpi/stick_main.png
new file mode 100644
index 0000000000..ae6d025a5c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png
new file mode 100644
index 0000000000..ca469c6a7c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png
new file mode 100644
index 0000000000..9b5445edcf
Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png differ
diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png
new file mode 100644
index 0000000000..c6dc232b44
Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png
new file mode 100644
index 0000000000..1e428dfe3e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png
new file mode 100644
index 0000000000..4dfb625961
Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png
new file mode 100644
index 0000000000..cc986c8ac9
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png
new file mode 100644
index 0000000000..ee688b09f6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png
new file mode 100644
index 0000000000..6b678d22c7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png
new file mode 100644
index 0000000000..bc9dc0beba
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png
new file mode 100644
index 0000000000..f61d849615
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png
new file mode 100644
index 0000000000..05847c34bd
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png
new file mode 100644
index 0000000000..87bac27dfa
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..1eccbe68dd
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png
new file mode 100644
index 0000000000..ffa1d200ed
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png
new file mode 100644
index 0000000000..23a5cec513
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..9ca7975bbe
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..fc3c434b08
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png
new file mode 100644
index 0000000000..013600d1f2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png
new file mode 100644
index 0000000000..2a0f1568fa
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..23706188b6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..b4d1b92b75
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png
new file mode 100644
index 0000000000..166bd052df
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png
new file mode 100644
index 0000000000..8d357b2284
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..32eb4faff6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-night/no_icon.png b/src/android/app/src/main/res/drawable-night/no_icon.png
new file mode 100644
index 0000000000..9a3969709f
Binary files /dev/null and b/src/android/app/src/main/res/drawable-night/no_icon.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a.png b/src/android/app/src/main/res/drawable-xhdpi/button_a.png
new file mode 100644
index 0000000000..4e20f2b0ed
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png
new file mode 100644
index 0000000000..f18edd07e8
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b.png b/src/android/app/src/main/res/drawable-xhdpi/button_b.png
new file mode 100644
index 0000000000..deb83a09d4
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png
new file mode 100644
index 0000000000..f583be0282
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l.png b/src/android/app/src/main/res/drawable-xhdpi/button_l.png
new file mode 100644
index 0000000000..d24039fbff
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png
new file mode 100644
index 0000000000..378ac87518
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r.png b/src/android/app/src/main/res/drawable-xhdpi/button_r.png
new file mode 100644
index 0000000000..7b01c043e3
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png
new file mode 100644
index 0000000000..9b3e3e75a5
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select.png b/src/android/app/src/main/res/drawable-xhdpi/button_select.png
new file mode 100644
index 0000000000..57abf56669
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png
new file mode 100644
index 0000000000..29eda72af6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start.png b/src/android/app/src/main/res/drawable-xhdpi/button_start.png
new file mode 100644
index 0000000000..f9cf0d6678
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png
new file mode 100644
index 0000000000..4d690fa7ed
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x.png b/src/android/app/src/main/res/drawable-xhdpi/button_x.png
new file mode 100644
index 0000000000..93a2ee9976
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png
new file mode 100644
index 0000000000..6bbd39646a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y.png b/src/android/app/src/main/res/drawable-xhdpi/button_y.png
new file mode 100644
index 0000000000..d979e98e06
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png
new file mode 100644
index 0000000000..a6c9bdb546
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png
new file mode 100644
index 0000000000..f94474fea5
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png
new file mode 100644
index 0000000000..8f7d5ab7a7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png
new file mode 100644
index 0000000000..a76658351a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png
new file mode 100644
index 0000000000..bbe4e64ce1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad.png b/src/android/app/src/main/res/drawable-xhdpi/dpad.png
new file mode 100644
index 0000000000..94ae844050
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png
new file mode 100644
index 0000000000..d6ccb2c4f8
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png
new file mode 100644
index 0000000000..2bba7749e2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..8398694013
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png
new file mode 100644
index 0000000000..02bc3d75a3
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png
new file mode 100644
index 0000000000..ac4b19ff42
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..8cff45f84e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png
new file mode 100644
index 0000000000..5e2787ba36
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png
new file mode 100644
index 0000000000..7819f220a8
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png
new file mode 100644
index 0000000000..a111c2ac78
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png
new file mode 100644
index 0000000000..774c54292e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png
new file mode 100644
index 0000000000..3f80cdf6c2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png
new file mode 100644
index 0000000000..2a7675ef78
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png
new file mode 100644
index 0000000000..ca1672caf0
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png
new file mode 100644
index 0000000000..999b4c01e1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png
new file mode 100644
index 0000000000..bb4de9bd96
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png
new file mode 100644
index 0000000000..8ed042e7e2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png
new file mode 100644
index 0000000000..86f5d535e2
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png
new file mode 100644
index 0000000000..9572c66f83
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png
new file mode 100644
index 0000000000..64bedc3260
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png
new file mode 100644
index 0000000000..abbcadede1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png
new file mode 100644
index 0000000000..07421767fd
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png
new file mode 100644
index 0000000000..42c3b7c43c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png
new file mode 100644
index 0000000000..0d1e56f6ae
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png
new file mode 100644
index 0000000000..4e9585bb4f
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png
new file mode 100644
index 0000000000..8c089e2375
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png
new file mode 100644
index 0000000000..0500f964f1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png
new file mode 100644
index 0000000000..56db5843d1
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png
new file mode 100644
index 0000000000..53c5ca0843
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png
new file mode 100644
index 0000000000..5d91cbfb0e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png
new file mode 100644
index 0000000000..f8ce9a0c62
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png
new file mode 100644
index 0000000000..981c8b0c82
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png
new file mode 100644
index 0000000000..82065e1269
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png
new file mode 100644
index 0000000000..b30b2e799a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png
new file mode 100644
index 0000000000..36b7ea1832
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png
new file mode 100644
index 0000000000..3715e1c11d
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png
new file mode 100644
index 0000000000..fb0d7fc5c4
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..e6812f0d48
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png
new file mode 100644
index 0000000000..05f4296149
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png
new file mode 100644
index 0000000000..63f162e529
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..0b9049f468
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png
new file mode 100644
index 0000000000..06cef9de3a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png
new file mode 100644
index 0000000000..e950c5b15f
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png
new file mode 100644
index 0000000000..3ac88ed9b0
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png
new file mode 100644
index 0000000000..a3491c80fe
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png
new file mode 100644
index 0000000000..16ca58c0f6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png
new file mode 100644
index 0000000000..e7fe0c2d5a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png
new file mode 100644
index 0000000000..8c47b2ba34
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png
new file mode 100644
index 0000000000..e364fae1e7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png
new file mode 100644
index 0000000000..08d65cc996
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png
new file mode 100644
index 0000000000..faae9b6f74
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png
new file mode 100644
index 0000000000..669780f288
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png
new file mode 100644
index 0000000000..888b147de7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png
new file mode 100644
index 0000000000..605493e3ef
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png
new file mode 100644
index 0000000000..90a93af8d6
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png
new file mode 100644
index 0000000000..4500cd2bea
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png
new file mode 100644
index 0000000000..b18b2fd596
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png
new file mode 100644
index 0000000000..53ed400e05
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png
new file mode 100644
index 0000000000..c55e568528
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png
new file mode 100644
index 0000000000..1507cc365a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png
new file mode 100644
index 0000000000..7ef2b883e8
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png
new file mode 100644
index 0000000000..f3f11ede2e
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png
new file mode 100644
index 0000000000..4ce679c695
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png
new file mode 100644
index 0000000000..926f5e2698
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png
new file mode 100644
index 0000000000..7faf8db3ba
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png
new file mode 100644
index 0000000000..cc56a749c4
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png
new file mode 100644
index 0000000000..ed1b6b683c
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png
new file mode 100644
index 0000000000..892fa74f13
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png
new file mode 100644
index 0000000000..6272f39e67
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png
new file mode 100644
index 0000000000..0cccd3a301
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png
new file mode 100644
index 0000000000..18a99ad2d4
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png
new file mode 100644
index 0000000000..69ae32dc30
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png
new file mode 100644
index 0000000000..c85074c60a
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png
new file mode 100644
index 0000000000..6f1550a108
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png
new file mode 100644
index 0000000000..2827a1777f
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png
new file mode 100644
index 0000000000..88e09b8a0b
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png
new file mode 100644
index 0000000000..edc920e8e0
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png
new file mode 100644
index 0000000000..a8b693494f
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png
new file mode 100644
index 0000000000..d157edca2d
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png
new file mode 100644
index 0000000000..2ac2440be7
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png differ
diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png
new file mode 100644
index 0000000000..71e67e02aa
Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png differ
diff --git a/src/android/app/src/main/res/drawable/gamelist_divider.xml b/src/android/app/src/main/res/drawable/gamelist_divider.xml
new file mode 100644
index 0000000000..7da9dccceb
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/gamelist_divider.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 0000000000..bdd99f48d5
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/drawable/no_icon.png b/src/android/app/src/main/res/drawable/no_icon.png
new file mode 100644
index 0000000000..1ce8fdc760
Binary files /dev/null and b/src/android/app/src/main/res/drawable/no_icon.png differ
diff --git a/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml
new file mode 100644
index 0000000000..9bcf883e16
--- /dev/null
+++ b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/activity_cheats.xml b/src/android/app/src/main/res/layout/activity_cheats.xml
new file mode 100644
index 0000000000..b9414ab6de
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_cheats.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml
new file mode 100644
index 0000000000..7d7f369254
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_emulation.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000000..cea0922a73
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000000..11b91c45f2
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml
new file mode 100644
index 0000000000..217f02d349
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_game.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/dialog_checkbox.xml b/src/android/app/src/main/res/layout/dialog_checkbox.xml
new file mode 100644
index 0000000000..c0f307117a
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_checkbox.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
new file mode 100644
index 0000000000..a81157a29f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/dialog_seekbar.xml b/src/android/app/src/main/res/layout/dialog_seekbar.xml
new file mode 100644
index 0000000000..35abecfcb3
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_seekbar.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml
new file mode 100644
index 0000000000..6449341712
--- /dev/null
+++ b/src/android/app/src/main/res/layout/filepicker_toolbar.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_cheat_details.xml b/src/android/app/src/main/res/layout/fragment_cheat_details.xml
new file mode 100644
index 0000000000..25b1a268a6
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_cheat_details.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_cheat_list.xml b/src/android/app/src/main/res/layout/fragment_cheat_list.xml
new file mode 100644
index 0000000000..679a49c282
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_cheat_list.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml
new file mode 100644
index 0000000000..d6e47e1e48
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_emulation.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_grid.xml b/src/android/app/src/main/res/layout/fragment_grid.xml
new file mode 100644
index 0000000000..f5b6c2e197
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_grid.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/fragment_settings.xml b/src/android/app/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 0000000000..4c5d597c17
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/list_item_cheat.xml b/src/android/app/src/main/res/layout/list_item_cheat.xml
new file mode 100644
index 0000000000..c0b5f982f8
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_cheat.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/list_item_setting.xml b/src/android/app/src/main/res/layout/list_item_setting.xml
new file mode 100644
index 0000000000..df83684f75
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_setting.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/list_item_setting_checkbox.xml b/src/android/app/src/main/res/layout/list_item_setting_checkbox.xml
new file mode 100644
index 0000000000..86ba83f117
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_setting_checkbox.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/list_item_settings_header.xml b/src/android/app/src/main/res/layout/list_item_settings_header.xml
new file mode 100644
index 0000000000..d220dfd615
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_settings_header.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/premium_item_setting.xml b/src/android/app/src/main/res/layout/premium_item_setting.xml
new file mode 100644
index 0000000000..17d5a13b25
--- /dev/null
+++ b/src/android/app/src/main/res/layout/premium_item_setting.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/layout/sysclock_datetime_picker.xml b/src/android/app/src/main/res/layout/sysclock_datetime_picker.xml
new file mode 100644
index 0000000000..d082f52833
--- /dev/null
+++ b/src/android/app/src/main/res/layout/sysclock_datetime_picker.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu/menu_emulation.xml b/src/android/app/src/main/res/menu/menu_emulation.xml
new file mode 100644
index 0000000000..b6c0d7cc46
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_emulation.xml
@@ -0,0 +1,118 @@
+
diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml
new file mode 100644
index 0000000000..9cdcc7f082
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_game_grid.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu/menu_settings.xml b/src/android/app/src/main/res/menu/menu_settings.xml
new file mode 100644
index 0000000000..1fe7aa6d4c
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_settings.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000000..c9ad5f98f1
--- /dev/null
+++ b/src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000000..57ea32d88e
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..18cc694d16
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000000..7052f40772
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..0e7cdeed61
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000..4d3e9fc411
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..b57a8d6232
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..d2c6d0692f
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..22f6eb36fe
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000000..1aa7f3ae28
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 0000000000..b57c8f75bc
Binary files /dev/null and b/src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/src/android/app/src/main/res/values-night/colors.xml b/src/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000000..43b9480216
--- /dev/null
+++ b/src/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,17 @@
+
+
+
+ #272727
+ #121212
+ #FEC303
+
+ #121212
+ #3D3D3D
+
+ #404040
+
+ #E0E0E0
+ #A0A0A0
+
+ @color/citra_accent
+
diff --git a/src/android/app/src/main/res/values-night/styles_filepicker.xml b/src/android/app/src/main/res/values-night/styles_filepicker.xml
new file mode 100644
index 0000000000..1a175cdcfc
--- /dev/null
+++ b/src/android/app/src/main/res/values-night/styles_filepicker.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/android/app/src/main/res/values-w1000dp/integers.xml b/src/android/app/src/main/res/values-w1000dp/integers.xml
new file mode 100644
index 0000000000..5cd4e24f38
--- /dev/null
+++ b/src/android/app/src/main/res/values-w1000dp/integers.xml
@@ -0,0 +1,4 @@
+
+
+ 4
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values-w1050dp/dimens.xml b/src/android/app/src/main/res/values-w1050dp/dimens.xml
new file mode 100644
index 0000000000..92fcb2b668
--- /dev/null
+++ b/src/android/app/src/main/res/values-w1050dp/dimens.xml
@@ -0,0 +1,6 @@
+
+
+
+ 96dp
+
diff --git a/src/android/app/src/main/res/values-w500dp/integers.xml b/src/android/app/src/main/res/values-w500dp/integers.xml
new file mode 100644
index 0000000000..d2955c0ae3
--- /dev/null
+++ b/src/android/app/src/main/res/values-w500dp/integers.xml
@@ -0,0 +1,4 @@
+
+
+ 2
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values-w750dp/integers.xml b/src/android/app/src/main/res/values-w750dp/integers.xml
new file mode 100644
index 0000000000..f049d8b44e
--- /dev/null
+++ b/src/android/app/src/main/res/values-w750dp/integers.xml
@@ -0,0 +1,4 @@
+
+
+ 3
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values-w820dp/dimens.xml b/src/android/app/src/main/res/values-w820dp/dimens.xml
new file mode 100644
index 0000000000..d27181e85e
--- /dev/null
+++ b/src/android/app/src/main/res/values-w820dp/dimens.xml
@@ -0,0 +1,5 @@
+
+
+ 64dp
+
diff --git a/src/android/app/src/main/res/values/arrays.xml b/src/android/app/src/main/res/values/arrays.xml
new file mode 100644
index 0000000000..c948e6a8b6
--- /dev/null
+++ b/src/android/app/src/main/res/values/arrays.xml
@@ -0,0 +1,174 @@
+
+
+
+
+
+ Device clock
+ Simulated clock
+
+
+
+ 0
+ 1
+
+
+
+ Light
+ Dark
+ System default
+
+
+
+ 0
+ 1
+ 2
+
+
+
+
+ Light
+ Dark
+
+
+
+
+ 0
+ 1
+
+
+
+ Auto-select
+ Japan
+ USA
+ Europe
+ Australia
+ China
+ Korea
+ Taiwan
+
+
+
+ -1
+ 0
+ 1
+ 2
+ 3
+ 4
+ 5
+ 6
+
+
+
+ Japanese (日本語)
+ English
+ French (français)
+ German (Deutsch)
+ Italian (italiano)
+ Spanish (español)
+ Simplified Chinese (简体中文)
+ Korean (한국어)
+ Dutch (Nederlands)
+ Portuguese (português)
+ Russian (Русский)
+ Traditional Chinese (正體中文)
+
+
+
+ 0
+ 1
+ 2
+ 3
+ 4
+ 5
+ 6
+ 7
+ 8
+ 9
+ 10
+ 11
+
+
+
+ a
+ b
+ x
+ y
+ L
+ R
+ ZL
+ ZR
+ Start
+ Select
+ D-Pad
+ Circle Pad
+ C Stick
+
+
+
+ Blank
+ Still Image
+ Device Camera
+
+
+
+ blank
+ image
+ ndk
+
+
+
+ Default
+ Any Front Camera
+ Any Back Camera
+
+
+
+
+ _front
+ _back
+
+
+
+ None
+ Horizontal
+ Vertical
+ Reverse
+
+
+
+ 0
+ 1
+ 2
+ 3
+
+
+
+ None
+ Real Device
+ Static Noise
+
+
+
+ 0
+ 1
+ 2
+
+
+
+ Off
+ Side by Side
+ Anaglyph
+ Interlaced
+ Reverse Interlaced
+ Cardboard VR
+
+
+
+ 0
+ 1
+ 2
+ 3
+ 4
+ 5
+
+
diff --git a/src/android/app/src/main/res/values/colors.xml b/src/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..6668288a70
--- /dev/null
+++ b/src/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+
+
+ #FFC303
+ #FF8D03
+ #CC7102
+
+ #ffffff
+ #D5D5D5
+
+ #ffffff
+
+ #1C1424
+ #5C5661
+
+ @color/header_text
+
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..b3d186d888
--- /dev/null
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,10 @@
+
+
+ 16dp
+
+ 4dp
+ 12dp
+ 16dp
+
+ 20dp
+
diff --git a/src/android/app/src/main/res/values/ic_launcher_background.xml b/src/android/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000000..c5d5899fdf
--- /dev/null
+++ b/src/android/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml
new file mode 100644
index 0000000000..9f6d8492ed
--- /dev/null
+++ b/src/android/app/src/main/res/values/integers.xml
@@ -0,0 +1,65 @@
+
+
+ 1
+
+
+ 930
+ 620
+ 870
+ 720
+ 870
+ 520
+ 810
+ 620
+ 15
+ 470
+ 13
+ 0
+ 13
+ 110
+ 895
+ 0
+ 895
+ 110
+ 740
+ 770
+ 100
+ 670
+ 470
+ 850
+ 550
+ 850
+ 450
+ 850
+
+
+ 810
+ 870
+ 710
+ 925
+ 710
+ 815
+ 610
+ 870
+ 10
+ 680
+ 10
+ 0
+ 10
+ 70
+ 810
+ 0
+ 810
+ 70
+ 800
+ 710
+ 80
+ 840
+ 360
+ 794
+ 400
+ 794
+ 520
+ 794
+
+
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..85422e8495
--- /dev/null
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,246 @@
+
+
+
+
+ yuzu
+ This software will run games for the Nintendo 3DS handheld game console. No game titles are included.\n\nBefore you run, please place your rightfully owned 3DS game files onto your device storage.
+ yuzu
+ yuzu
+ yuzu Switch emulator notifications
+ yuzu is running
+
+
+ Circle Pad
+ C-Stick
+ Triggers
+ D-Pad
+ Up/Down Axis
+ Left/Right Axis
+ Input Binding
+ Press or move an input to bind it to %1$s.
+ Move your joystick up or down.
+ Move your joystick left or right.
+ A
+ B
+ SELECT
+ START
+ X
+ Y
+ L
+ R
+ ZL
+ ZR
+ This control must be bound to a gamepad analog stick or D-pad axis!
+ This control must be bound to a gamepad button!
+
+
+ Buttons
+
+
+ Change Theme (Light, Dark)
+ Theme will update when exiting Settings
+
+
+ Enable CPU JIT
+ Uses the Just-in-Time (JIT) compiler for CPU emulation. When enabled, game performance will be significantly improved.
+ System clock type
+ Set the emulated 3DS clock to either reflect that of your device or start at a simulated date and time.
+
+
+ System clock starting time override
+ If the \"System clock type\" setting is set to \"Simulated clock\", this changes the fixed date and time to start at.
+ Emulated region
+ Emulated language
+
+
+ Inner Camera
+ Outer Left Camera
+ Outer Right Camera
+ Image Source
+ Sets the image source of the virtual camera. You can use an image file, or a device camera when supported.
+ Camera Device
+ If the \"Image Source\" setting is set to \"Device Camera\", this sets the physical camera to use.
+ Front
+ Back
+ External
+ Image Flip
+
+
+ Renderer
+ Enable V-Sync
+ Synchronizes the game frame rate to the refresh rate of your device.
+ Enable linear filtering
+ Enables linear filtering, which causes game visuals to appear smoother.
+ Texture Filter
+ Enhances the visuals of games by applying a filter to textures. The supported filters are Anime4K Ultrafast, Bicubic, ScaleForce, and xBRZ freescale.
+ Enable hardware renderer
+ Uses hardware to emulate 3DS graphics. When enabled, game performance will be significantly improved.
+ Enable hardware shader
+ Uses hardware to emulate 3DS shaders. When enabled, game performance will be significantly improved.
+ Enable accurate shader multiplication
+ Uses more accurate multiplication in hardware shaders, which may fix some graphical bugs. When enabled, performance will be reduced.
+ Enable asynchronous GPU emulation
+ Uses a separate thread to emulate the GPU asynchronously. When enabled, performance will be improved.
+ Enable limit speed
+ When enabled, emulation speed will be limited to a specified percentage of normal speed.
+ Limit speed percent
+ Specifies the percentage to limit emulation speed. With the default of 100% emulation will be limited to normal speed. Values higher or lower will increase or decrease the speed limit.
+ Internal resolution
+ Specifies the resolution used to render at. A high resolution will improve visual quality a lot but is also quite heavy on performance and might cause glitches in certain games.
+ Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.
+ Warning: Modifying these settings will slow emulation
+ Stereoscopy
+ Stereoscopic 3D Mode
+ Depth
+ Specifies the value of the 3D slider. This should be set to higher than 0% when Stereoscopic 3D is enabled.
+ Cardboard VR
+ Cardboard Screen size
+ Scales the screen to a percentage of its original size.
+ Horizontal shift
+ Specifies the percentage of empty space to shift the screens horizontally. Positive values move the two eyes closer to the middle, while negative values move them away.
+ Vertical shift
+ Specifies the percentage of empty space to shift the screens vertically. Positive values move the two eyes towards the bottom, while negative values move them towards the top.
+ Use shader JIT
+ Use disk shader cache
+ Reduce stuttering by storing and loading generated shaders to disk. It cannot be used without Enabling Hardware Shader.
+ Utility
+ Dump textures
+ Dumps textures to dump/textures/[GAME ID]
+ Use custom textures
+ Uses custom textures found in load/textures/[GAME ID]
+ Preload custom textures
+ Loads all custom textures into memory. This feature can use a lot of memory.
+
+ Premium
+ Upgrade to Premium and support yuzu!
+ With Premium, you will support the developers to continue improving yuzu, and gain access to these exclusive features!
+ Welcome to Premium.
+ Thank you for your support!
+
+
+ Enable audio stretching
+ Stretches audio to reduce stuttering. When enabled, increases audio latency and slightly reduces performance.
+ Audio Input Device
+
+
+ Clear
+ Default
+ Saved settings
+ Saved settings for %1$s
+ Error saving %1$s.ini: %2$s
+ Loading...
+
+
+ Settings
+
+
+ Select Game Folder
+ Install CIA
+
+
+ Settings
+ Premium
+ General
+ System
+ Camera
+ Gamepad
+ Graphics
+ Audio
+ Debug
+
+
+ Your ROM is encrypted
+ Invalid ROM format
+
+
+ Save State
+ Load State
+ Slot %1$d
+ Slot %1$d - %2$tF %2$tR
+ Show FPS
+ Configure Controls
+ Edit Layout
+ Done
+ Toggle Controls
+ Adjust Scale
+ Relative Stick Center
+ Enable D-Pad Sliding
+ Open Settings
+ Open Cheats
+ Landscape Screen Layout
+ Default
+ Portrait
+ Single Screen
+ Side by Side Screens
+ Swap Screens
+ Reset Overlay
+ Show Overlay
+ Close Game
+ Are you sure that you would like to close the current game?
+ Amiibo
+ Load
+ Remove
+ Select Amiibo file
+ Error loading Amiibo
+ While loading the specified Amiibo file, an error occurred. Please check that the file is correct.
+
+ You need to allow write access to external storage for the emulator to work
+ Loading Settings...
+
+ The external storage needs to be available in order to use yuzu
+
+ Select This Directory
+ No files were found or no game directory has been selected yet.
+
+ Do not show this again
+ Savestates
+ Warning: Savestates are NOT a replacement for in-game saves, and are not meant to be reliable.\n\nUse at your own risk!
+
+
+ Software Keyboard
+ I Forgot
+ Text length is not correct (should be %d characters)
+ Text is too long (should be no more than %d characters)
+ Blank input is not allowed
+ Empty input is not allowed
+
+
+ Mii Selector
+ Standard Mii
+
+
+ Select Image
+ Camera
+ yuzu needs to access your camera to emulate the 3DS\'s cameras.\n\nAlternatively, you can also set \"Image Source\" to \"Still Image\" in Camera Settings.
+
+
+ Microphone
+ yuzu needs to access your microphone to emulate the 3DS\'s microphone.\n\nAlternatively, you can also change \"Audio Input Device\" in Audio Settings.
+
+
+ Abort
+ Continue
+ System Archive Not Found
+ %s is missing. Please dump your system archives.\nContinuing emulation may result in crashes and bugs.
+ A system archive
+ Save/Load Error
+ Fatal Error
+ A fatal error occurred. Check the log for details.\nContinuing emulation may result in crashes and bugs.
+
+
+ Preparing shaders
+ Building shaders
+
+
+ Cheats
+ Add Cheat
+ Name
+ Notes
+ Code
+ Edit
+ Delete
+ Are you sure you want to delete \"%1$s\"?
+ Name can\'t be empty
+ Code can\'t be empty
+ Error on line %1$d
+
diff --git a/src/android/app/src/main/res/values/styles.xml b/src/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..47fe6f6ea0
--- /dev/null
+++ b/src/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/values/styles_filepicker.xml b/src/android/app/src/main/res/values/styles_filepicker.xml
new file mode 100644
index 0000000000..0b0c3fe1ad
--- /dev/null
+++ b/src/android/app/src/main/res/values/styles_filepicker.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/android/app/src/test/java/org/citra/citra_emu/ExampleUnitTest.java b/src/android/app/src/test/java/org/citra/citra_emu/ExampleUnitTest.java
new file mode 100644
index 0000000000..4e4bb317fb
--- /dev/null
+++ b/src/android/app/src/test/java/org/citra/citra_emu/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package org.citra.citra_emu;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/src/android/build.gradle b/src/android/build.gradle
new file mode 100644
index 0000000000..9d7571a4eb
--- /dev/null
+++ b/src/android/build.gradle
@@ -0,0 +1,26 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:7.3.1'
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
diff --git a/src/android/code-style-java.xml b/src/android/code-style-java.xml
new file mode 100644
index 0000000000..a8ed003c8f
--- /dev/null
+++ b/src/android/code-style-java.xml
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+ Namespace:
+
+
+
+
+
+
+
+
+ xmlns:.*
+ Namespace:
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+ ^$
+
+
+
+
+
+
+
+
+ style
+ ^$
+
+
+
+
+
+
+
+
+ .*
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:layout_width
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:layout_height
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:layout_.*
+ http://schemas.android.com/apk/res/android
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:width
+ http://schemas.android.com/apk/res/android
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:height
+ http://schemas.android.com/apk/res/android
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+ http://schemas.android.com/apk/res/android
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
diff --git a/src/android/gradle.properties b/src/android/gradle.properties
new file mode 100644
index 0000000000..8de505811b
--- /dev/null
+++ b/src/android/gradle.properties
@@ -0,0 +1,15 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+android.enableJetifier=true
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx1536m
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
diff --git a/src/android/gradle/wrapper/gradle-wrapper.jar b/src/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..7a3265ee94
Binary files /dev/null and b/src/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/src/android/gradle/wrapper/gradle-wrapper.properties b/src/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..bd08889e59
--- /dev/null
+++ b/src/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Sun Feb 21 18:16:59 EST 2021
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
diff --git a/src/android/gradlew b/src/android/gradlew
new file mode 100644
index 0000000000..cccdd3d517
--- /dev/null
+++ b/src/android/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/src/android/gradlew.bat b/src/android/gradlew.bat
new file mode 100644
index 0000000000..f9553162f1
--- /dev/null
+++ b/src/android/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/src/android/settings.gradle b/src/android/settings.gradle
new file mode 100644
index 0000000000..e7b4def49c
--- /dev/null
+++ b/src/android/settings.gradle
@@ -0,0 +1 @@
+include ':app'