summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main/java/org
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app/src/main/java/org')
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java56
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java631
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java38
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java755
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java247
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java122
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java264
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java65
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java140
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java138
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java57
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java13
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java177
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java174
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java46
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java56
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java161
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java72
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java23
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java23
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java23
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java42
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java55
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java132
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java23
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java80
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java40
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java14
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java382
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java12
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java59
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java107
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java60
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java101
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java82
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java21
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java215
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java124
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java103
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java487
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java136
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java416
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java78
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java48
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java54
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java47
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java32
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java55
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java57
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java49
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java76
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java45
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java45
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java341
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java120
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java380
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/model/Game.java76
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java276
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java138
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java878
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java122
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java193
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java264
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java130
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java37
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java267
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java82
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java25
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java86
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java42
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java21
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java5
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java38
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java22
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java215
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java66
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java186
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java22
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java78
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java73
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java37
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java63
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java27
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java39
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java35
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java45
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java57
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java45
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java34
-rw-r--r--src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java46
90 files changed, 11169 insertions, 0 deletions
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 000000000..41ac7e27c
--- /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 000000000..baff99dc8
--- /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<EmulationActivity> 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 <a href=\"https://citra-emu.org/wiki/dumping-game-cartridges/\">game cartidges</a> or <a href=\"https://citra-emu.org/wiki/dumping-installed-titles/\">installed titles</a>.", 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 000000000..3083286e2
--- /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<File> 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 000000000..47ef0fd23
--- /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<InputDevice.MotionRange> 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 000000000..bc791638a
--- /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<GameViewHolder> 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 000000000..3586a9b34
--- /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<String> 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 000000000..7be5f6d97
--- /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 000000000..701cb0710
--- /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 000000000..0f10f1858
--- /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<Float> 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<InputDevice.MotionRange> 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 000000000..d6d14cc5f
--- /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 000000000..93b026364
--- /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 000000000..5748162bb
--- /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 000000000..66f4202d8
--- /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<Cheat> mSelectedCheat = new MutableLiveData<>(null);
+ private final MutableLiveData<Boolean> mIsAdding = new MutableLiveData<>(false);
+ private final MutableLiveData<Boolean> mIsEditing = new MutableLiveData<>(false);
+
+ private final MutableLiveData<Integer> mCheatAddedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData<Integer> mCheatChangedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData<Integer> mCheatDeletedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData<Boolean> 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<Cheat> getSelectedCheat() {
+ return mSelectedCheat;
+ }
+
+ public void setSelectedCheat(Cheat cheat, int position) {
+ if (mIsEditing.getValue()) {
+ setIsEditing(false);
+ }
+
+ mSelectedCheat.setValue(cheat);
+ mSelectedCheatPosition = position;
+ }
+
+ public LiveData<Boolean> getIsAdding() {
+ return mIsAdding;
+ }
+
+ public LiveData<Boolean> 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<Integer> 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<Integer> 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<Integer> 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<Boolean> 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 000000000..762cdb80e
--- /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 000000000..6c67a31d4
--- /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 000000000..8ba8f86e7
--- /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 000000000..a36bf427c
--- /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 000000000..9cb2ce8d8
--- /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<CheatViewHolder> {
+ 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 000000000..932dcf1d3
--- /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 000000000..275f0ecea
--- /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 000000000..f712e5bfa
--- /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 000000000..b762847c9
--- /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 000000000..0a291aa6b
--- /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<String, Setting> 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<String, Setting> 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 000000000..9684966f2
--- /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<String, List<String>> 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<String, SettingSection> 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<String, SettingSection> {
+ @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<String, SettingSection> sections = new Settings.SettingsSectionMap();
+
+ public SettingSection getSection(String sectionName) {
+ return sections.get(sectionName);
+ }
+
+ public boolean isEmpty() {
+ return sections.isEmpty();
+ }
+
+ public HashMap<String, SettingSection> 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<String, List<String>> 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<String, SettingSection> updatedSections) {
+ for (Map.Entry<String, SettingSection> 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<String, List<String>> entry : configFileSectionsMap.entrySet()) {
+ String fileName = entry.getKey();
+ List<String> sectionNames = entry.getValue();
+ TreeMap<String, SettingSection> 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 000000000..b906b7010
--- /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 000000000..baf40709f
--- /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 000000000..afc3352cc
--- /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 000000000..bac8876cd
--- /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 000000000..e9141a208
--- /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 000000000..2b1793d3e
--- /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 000000000..c0560d2dc
--- /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 000000000..305352022
--- /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 000000000..ee9d225d6
--- /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 000000000..551b13f99
--- /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 000000000..057145d9d
--- /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 000000000..9d44a923f
--- /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 000000000..23c3c4c9e
--- /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 000000000..0d63873bb
--- /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 000000000..0d26d48a7
--- /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 000000000..bfd7c71a9
--- /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<SettingViewHolder>
+ implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener {
+ private SettingsFragmentView mView;
+ private Context mContext;
+ private ArrayList<SettingsItem> 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<SettingsItem> 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 000000000..5799dcb8d
--- /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<SettingsItem> 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 000000000..31f3e68eb
--- /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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<String> supportedCameraNameList = new ArrayList<>();
+ ArrayList<String> 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<String> cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames)));
+ cameraDeviceNameList.addAll(supportedCameraNameList);
+ ArrayList<String> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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<SettingsItem> 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 000000000..c36eb55a7
--- /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<SettingsItem> 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 000000000..67bde5709
--- /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 000000000..d914f7d0b
--- /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 000000000..09ea93010
--- /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 000000000..baf80ed76
--- /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 000000000..7d95c250a
--- /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 000000000..be0853ff0
--- /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 000000000..2643ea121
--- /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 000000000..a175af9f8
--- /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 000000000..3dd048a29
--- /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 000000000..cb8c3e92a
--- /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 000000000..8ae6b70d7
--- /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<String, String> 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<String, SettingSection> readFile(final File ini, boolean isCustomGame, SettingsActivityView view) {
+ HashMap<String, SettingSection> 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<String, SettingSection> 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<String, SettingSection> 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<String, SettingSection> sections,
+ SettingsActivityView view) {
+ File ini = getSettingsFile(fileName);
+
+ try {
+ Wini writer = new Wini(ini);
+
+ Set<String> 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<String, SettingSection> sections) {
+ Set<String> sortedSections = new TreeSet<>(sections.keySet());
+
+ for (String sectionKey : sortedSections) {
+ SettingSection section = sections.get(sectionKey);
+
+ HashMap<String, Setting> settings = section.getSettings();
+ Set<String> 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<String, Setting> settings = section.getSettings();
+ Set<String> 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 000000000..c18ecd4c3
--- /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<String> 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 000000000..cdb40d6f8
--- /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 000000000..a4ffc59c7
--- /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 000000000..8232d0489
--- /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<String> allowedExtensions = new HashSet<String>(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<String> 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<String> 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<Cursor> 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 000000000..33b289fc4
--- /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 000000000..cdb2f7666
--- /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<InputOverlayDrawableButton> overlayButtons = new HashSet<>();
+ private final Set<InputOverlayDrawableDpad> overlayDpads = new HashSet<>();
+ private final Set<InputOverlayDrawableJoystick> 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.
+ * <p>
+ * This works due to the way the X and Y coordinates are stored within
+ * the {@link SharedPreferences}.
+ * <p>
+ * 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.
+ * <p>
+ * This has a few benefits over the conventional way of storing the values
+ * (ie. within the Citra ini file).
+ * <ul>
+ * <li>No native calls</li>
+ * <li>Keeps Android-only values inside the Android environment</li>
+ * </ul>
+ * <p>
+ * 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 000000000..81352296c
--- /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 000000000..87f3b7cd9
--- /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 000000000..956a8b1e9
--- /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 000000000..96ccc08bb
--- /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 000000000..d07fe30d8
--- /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 000000000..4ba419a48
--- /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 000000000..4e9994c2a
--- /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 000000000..de7c04875
--- /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 000000000..9fc30796f
--- /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 000000000..9d8040e1b
--- /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 000000000..4332121eb
--- /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 000000000..886846ec5
--- /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<T> {
+ 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 000000000..7578c353f
--- /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 000000000..dfbab1780
--- /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<K, V> {
+ private Map<K, V> forward = new HashMap<K, V>();
+ private Map<V, K> backward = new HashMap<V, K>();
+
+ 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 000000000..5dc54c235
--- /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<Purchase> 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<SkuDetails> 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<String> 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 000000000..f801a05f0
--- /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 000000000..58e552f5e
--- /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 000000000..5d1e951ca
--- /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<DirectoryInitializationState> callback;
+
+ public DirectoryStateReceiver(Action1<DirectoryInitializationState> 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 000000000..9664f8464
--- /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 000000000..baf691f5c
--- /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<String> 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<String> 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<Uri> 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<Uri> 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 000000000..f9025171b
--- /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 000000000..31c415779
--- /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 000000000..b790c2480
--- /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 000000000..070d01eb1
--- /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 000000000..a29e23e8d
--- /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 000000000..892b46387
--- /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 000000000..c99726685
--- /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 000000000..9112bf90c
--- /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 000000000..74ef3867f
--- /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 000000000..50dbcbe18
--- /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;
+ }
+}