summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main/java/org/citra/citra_emu/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app/src/main/java/org/citra/citra_emu/features')
-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
44 files changed, 4443 insertions, 0 deletions
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());
+ }
+ }
+}