diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle
index c7bb90131..1e70d8d99 100644
--- a/src/android/app/build.gradle
+++ b/src/android/app/build.gradle
@@ -10,7 +10,7 @@ def buildType
def abiFilter = "arm64-v8a" //, "x86"
android {
- compileSdkVersion 29
+ compileSdkVersion 31
ndkVersion "23.1.7779620"
compileOptions {
@@ -109,6 +109,10 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.2.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1'
+ implementation 'androidx.fragment:fragment:1.5.1'
+ implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
implementation 'com.google.android.material:material:1.1.0'
// For loading huge screenshots from the disk.
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
index b51382d21..2933d6028 100644
--- a/src/android/app/src/main/AndroidManifest.xml
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -68,6 +68,12 @@
+
+
mSelectedCheat = new MutableLiveData<>(null);
+ private final MutableLiveData mIsAdding = new MutableLiveData<>(false);
+ private final MutableLiveData mIsEditing = new MutableLiveData<>(false);
+
+ private final MutableLiveData mCheatAddedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mCheatChangedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mCheatDeletedEvent = new MutableLiveData<>(null);
+ private final MutableLiveData mOpenDetailsViewEvent = new MutableLiveData<>(false);
+
+ private Cheat[] mCheats;
+ private boolean mCheatsNeedSaving = false;
+
+ public void load() {
+ mCheats = CheatEngine.getCheats();
+
+ for (int i = 0; i < mCheats.length; i++) {
+ int position = i;
+ mCheats[i].setEnabledChangedCallback(() -> {
+ mCheatsNeedSaving = true;
+ notifyCheatUpdated(position);
+ });
+ }
+ }
+
+ public void saveIfNeeded() {
+ if (mCheatsNeedSaving) {
+ CheatEngine.saveCheatFile();
+ mCheatsNeedSaving = false;
+ }
+ }
+
+ public Cheat[] getCheats() {
+ return mCheats;
+ }
+
+ public LiveData getSelectedCheat() {
+ return mSelectedCheat;
+ }
+
+ public void setSelectedCheat(Cheat cheat, int position) {
+ if (mIsEditing.getValue()) {
+ setIsEditing(false);
+ }
+
+ mSelectedCheat.setValue(cheat);
+ mSelectedCheatPosition = position;
+ }
+
+ public LiveData getIsAdding() {
+ return mIsAdding;
+ }
+
+ public LiveData getIsEditing() {
+ return mIsEditing;
+ }
+
+ public void setIsEditing(boolean isEditing) {
+ mIsEditing.setValue(isEditing);
+
+ if (mIsAdding.getValue() && !isEditing) {
+ mIsAdding.setValue(false);
+ setSelectedCheat(null, -1);
+ }
+ }
+
+ /**
+ * When a cheat is added, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatAddedEvent() {
+ return mCheatAddedEvent;
+ }
+
+ private void notifyCheatAdded(int position) {
+ mCheatAddedEvent.setValue(position);
+ mCheatAddedEvent.setValue(null);
+ }
+
+ public void startAddingCheat() {
+ mSelectedCheat.setValue(null);
+ mSelectedCheatPosition = -1;
+
+ mIsAdding.setValue(true);
+ mIsEditing.setValue(true);
+ }
+
+ public void finishAddingCheat(Cheat cheat) {
+ if (!mIsAdding.getValue()) {
+ throw new IllegalStateException();
+ }
+
+ mIsAdding.setValue(false);
+ mIsEditing.setValue(false);
+
+ int position = mCheats.length;
+
+ CheatEngine.addCheat(cheat);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatAdded(position);
+ setSelectedCheat(mCheats[position], position);
+ }
+
+ /**
+ * When a cheat is edited, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatUpdatedEvent() {
+ return mCheatChangedEvent;
+ }
+
+ /**
+ * Notifies that an edit has been made to the contents of the cheat at the given position.
+ */
+ private void notifyCheatUpdated(int position) {
+ mCheatChangedEvent.setValue(position);
+ mCheatChangedEvent.setValue(null);
+ }
+
+ public void updateSelectedCheat(Cheat newCheat) {
+ CheatEngine.updateCheat(mSelectedCheatPosition, newCheat);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatUpdated(mSelectedCheatPosition);
+ setSelectedCheat(mCheats[mSelectedCheatPosition], mSelectedCheatPosition);
+ }
+
+ /**
+ * When a cheat is deleted, the integer stored in the returned LiveData
+ * changes to the position of that cheat, then changes back to null.
+ */
+ public LiveData getCheatDeletedEvent() {
+ return mCheatDeletedEvent;
+ }
+
+ /**
+ * Notifies that the cheat at the given position has been deleted.
+ */
+ private void notifyCheatDeleted(int position) {
+ mCheatDeletedEvent.setValue(position);
+ mCheatDeletedEvent.setValue(null);
+ }
+
+ public void deleteSelectedCheat() {
+ int position = mSelectedCheatPosition;
+
+ setSelectedCheat(null, -1);
+
+ CheatEngine.removeCheat(position);
+
+ mCheatsNeedSaving = true;
+ load();
+
+ notifyCheatDeleted(position);
+ }
+
+ public LiveData getOpenDetailsViewEvent() {
+ return mOpenDetailsViewEvent;
+ }
+
+ public void openDetailsView() {
+ mOpenDetailsViewEvent.setValue(true);
+ mOpenDetailsViewEvent.setValue(false);
+ }
+}
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java
new file mode 100644
index 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 {
+ 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/ui/TwoPaneOnBackPressedCallback.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java
new file mode 100644
index 000000000..d07fe30d8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java
@@ -0,0 +1,37 @@
+package org.citra.citra_emu.ui;
+
+import android.view.View;
+
+import androidx.activity.OnBackPressedCallback;
+import androidx.annotation.NonNull;
+import androidx.slidingpanelayout.widget.SlidingPaneLayout;
+
+public class TwoPaneOnBackPressedCallback extends OnBackPressedCallback
+ implements SlidingPaneLayout.PanelSlideListener {
+ private final SlidingPaneLayout mSlidingPaneLayout;
+
+ public TwoPaneOnBackPressedCallback(@NonNull SlidingPaneLayout slidingPaneLayout) {
+ super(slidingPaneLayout.isSlideable() && slidingPaneLayout.isOpen());
+ mSlidingPaneLayout = slidingPaneLayout;
+ slidingPaneLayout.addPanelSlideListener(this);
+ }
+
+ @Override
+ public void handleOnBackPressed() {
+ mSlidingPaneLayout.close();
+ }
+
+ @Override
+ public void onPanelSlide(@NonNull View panel, float slideOffset) {
+ }
+
+ @Override
+ public void onPanelOpened(@NonNull View panel) {
+ setEnabled(true);
+ }
+
+ @Override
+ public void onPanelClosed(@NonNull View panel) {
+ setEnabled(false);
+ }
+}
diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
index 443b39d57..6cc0cf906 100644
--- a/src/android/app/src/main/jni/CMakeLists.txt
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -11,6 +11,9 @@ add_library(citra-android SHARED
camera/ndk_camera.h
camera/still_image_camera.cpp
camera/still_image_camera.h
+ cheats/cheat.cpp
+ cheats/cheat.h
+ cheats/cheat_engine.cpp
config.cpp
config.h
default_ini.h
diff --git a/src/android/app/src/main/jni/cheats/cheat.cpp b/src/android/app/src/main/jni/cheats/cheat.cpp
new file mode 100644
index 000000000..3d93ab890
--- /dev/null
+++ b/src/android/app/src/main/jni/cheats/cheat.cpp
@@ -0,0 +1,84 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include "jni/cheats/cheat.h"
+
+#include
+#include
+#include
+
+#include
+
+#include "common/string_util.h"
+#include "core/cheats/cheat_base.h"
+#include "core/cheats/gateway_cheat.h"
+#include "jni/android_common/android_common.h"
+#include "jni/id_cache.h"
+
+std::shared_ptr* CheatFromJava(JNIEnv* env, jobject cheat) {
+ return reinterpret_cast*>(
+ env->GetLongField(cheat, IDCache::GetCheatPointer()));
+}
+
+jobject CheatToJava(JNIEnv* env, std::shared_ptr cheat) {
+ return env->NewObject(
+ IDCache::GetCheatClass(), IDCache::GetCheatConstructor(),
+ reinterpret_cast(new std::shared_ptr(std::move(cheat))));
+}
+
+extern "C" {
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_features_cheats_model_Cheat_finalize(JNIEnv* env,
+ jobject obj) {
+ delete CheatFromJava(env, obj);
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_Cheat_getName(JNIEnv* env, jobject obj) {
+ return ToJString(env, (*CheatFromJava(env, obj))->GetName());
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_Cheat_getNotes(JNIEnv* env, jobject obj) {
+ return ToJString(env, (*CheatFromJava(env, obj))->GetComments());
+}
+
+JNIEXPORT jstring JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_Cheat_getCode(JNIEnv* env, jobject obj) {
+ return ToJString(env, (*CheatFromJava(env, obj))->GetCode());
+}
+
+JNIEXPORT jboolean JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_Cheat_getEnabled(JNIEnv* env, jobject obj) {
+ return static_cast((*CheatFromJava(env, obj))->IsEnabled());
+}
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_features_cheats_model_Cheat_setEnabledImpl(
+ JNIEnv* env, jobject obj, jboolean j_enabled) {
+ (*CheatFromJava(env, obj))->SetEnabled(static_cast(j_enabled));
+}
+
+JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_features_cheats_model_Cheat_isValidGatewayCode(
+ JNIEnv* env, jclass, jstring j_code) {
+ const std::string code = GetJString(env, j_code);
+ std::vector code_lines;
+ Common::SplitString(code, '\n', code_lines);
+
+ for (int i = 0; i < code_lines.size(); ++i) {
+ Cheats::GatewayCheat::CheatLine cheat_line(code_lines[i]);
+ if (!cheat_line.valid) {
+ return i + 1;
+ }
+ }
+
+ return 0;
+}
+
+JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_features_cheats_model_Cheat_createGatewayCode(
+ JNIEnv* env, jclass, jstring j_name, jstring j_notes, jstring j_code) {
+ return CheatToJava(env, std::make_shared(GetJString(env, j_name),
+ GetJString(env, j_code),
+ GetJString(env, j_notes)));
+}
+}
diff --git a/src/android/app/src/main/jni/cheats/cheat.h b/src/android/app/src/main/jni/cheats/cheat.h
new file mode 100644
index 000000000..078ac7b7f
--- /dev/null
+++ b/src/android/app/src/main/jni/cheats/cheat.h
@@ -0,0 +1,14 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+
+#include
+
+namespace Cheats {
+class CheatBase;
+}
+
+std::shared_ptr* CheatFromJava(JNIEnv* env, jobject cheat);
+jobject CheatToJava(JNIEnv* env, std::shared_ptr cheat);
diff --git a/src/android/app/src/main/jni/cheats/cheat_engine.cpp b/src/android/app/src/main/jni/cheats/cheat_engine.cpp
new file mode 100644
index 000000000..61dd7354c
--- /dev/null
+++ b/src/android/app/src/main/jni/cheats/cheat_engine.cpp
@@ -0,0 +1,51 @@
+// Copyright 2022 Citra Emulator Project
+// Licensed under GPLv2 or any later version
+// Refer to the license.txt file included.
+
+#include
+#include
+
+#include
+
+#include "core/cheats/cheat_base.h"
+#include "core/cheats/cheats.h"
+#include "core/core.h"
+#include "jni/cheats/cheat.h"
+#include "jni/id_cache.h"
+
+extern "C" {
+
+JNIEXPORT jobjectArray JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_CheatEngine_getCheats(JNIEnv* env, jclass) {
+ auto cheats = Core::System::GetInstance().CheatEngine().GetCheats();
+
+ const jobjectArray array =
+ env->NewObjectArray(static_cast(cheats.size()), IDCache::GetCheatClass(), nullptr);
+
+ jsize i = 0;
+ for (auto& cheat : cheats)
+ env->SetObjectArrayElement(array, i++, CheatToJava(env, std::move(cheat)));
+
+ return array;
+}
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_features_cheats_model_CheatEngine_addCheat(
+ JNIEnv* env, jclass, jobject j_cheat) {
+ Core::System::GetInstance().CheatEngine().AddCheat(*CheatFromJava(env, j_cheat));
+}
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_features_cheats_model_CheatEngine_removeCheat(
+ JNIEnv* env, jclass, jint index) {
+ Core::System::GetInstance().CheatEngine().RemoveCheat(index);
+}
+
+JNIEXPORT void JNICALL Java_org_citra_citra_1emu_features_cheats_model_CheatEngine_updateCheat(
+ JNIEnv* env, jclass, jint index, jobject j_new_cheat) {
+ Core::System::GetInstance().CheatEngine().UpdateCheat(index, *CheatFromJava(env, j_new_cheat));
+}
+
+JNIEXPORT void JNICALL
+Java_org_citra_citra_1emu_features_cheats_model_CheatEngine_saveCheatFile(JNIEnv* env, jclass) {
+ Core::System::GetInstance().CheatEngine().SaveCheatFile();
+}
+}
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
index cf1c24437..60c091912 100644
--- a/src/android/app/src/main/jni/id_cache.cpp
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -18,11 +18,12 @@ static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
static JavaVM* s_java_vm;
-static jclass s_native_library_class;
static jclass s_core_error_class;
static jclass s_savestate_info_class;
static jclass s_disk_cache_progress_class;
static jclass s_load_callback_stage_class;
+
+static jclass s_native_library_class;
static jmethodID s_on_core_error;
static jmethodID s_display_alert_msg;
static jmethodID s_display_alert_prompt;
@@ -34,6 +35,10 @@ static jmethodID s_request_camera_permission;
static jmethodID s_request_mic_permission;
static jmethodID s_disk_cache_load_progress;
+static jclass s_cheat_class;
+static jfieldID s_cheat_pointer;
+static jmethodID s_cheat_constructor;
+
static std::unordered_map s_java_load_callback_stages;
namespace IDCache {
@@ -57,10 +62,6 @@ JNIEnv* GetEnvForThread() {
return owned.env;
}
-jclass GetNativeLibraryClass() {
- return s_native_library_class;
-}
-
jclass GetCoreErrorClass() {
return s_core_error_class;
}
@@ -77,6 +78,10 @@ jclass GetDiskCacheLoadCallbackStageClass() {
return s_load_callback_stage_class;
}
+jclass GetNativeLibraryClass() {
+ return s_native_library_class;
+}
+
jmethodID GetOnCoreError() {
return s_on_core_error;
}
@@ -117,6 +122,18 @@ jmethodID GetDiskCacheLoadProgress() {
return s_disk_cache_load_progress;
}
+jclass GetCheatClass() {
+ return s_cheat_class;
+}
+
+jfieldID GetCheatPointer() {
+ return s_cheat_pointer;
+}
+
+jmethodID GetCheatConstructor() {
+ return s_cheat_constructor;
+}
+
jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage) {
const auto it = s_java_load_callback_stages.find(stage);
ASSERT_MSG(it != s_java_load_callback_stages.end(), "Invalid LoadCallbackStage: {}", stage);
@@ -147,9 +164,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
FileUtil::GetUserPath(FileUtil::UserPath::LogDir) + LOG_FILE));
LOG_INFO(Frontend, "Logging backend initialised");
- // Initialize Java classes
- const jclass native_library_class = env->FindClass("org/citra/citra_emu/NativeLibrary");
- s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class));
+ // Initialize misc classes
s_savestate_info_class = reinterpret_cast(
env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SavestateInfo")));
s_core_error_class = reinterpret_cast(
@@ -159,7 +174,9 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass(
"org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage")));
- // Initialize Java methods
+ // Initialize NativeLibrary
+ const jclass native_library_class = env->FindClass("org/citra/citra_emu/NativeLibrary");
+ s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class));
s_on_core_error = env->GetStaticMethodID(
s_native_library_class, "OnCoreError",
"(Lorg/citra/citra_emu/NativeLibrary$CoreError;Ljava/lang/String;)Z");
@@ -182,6 +199,14 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
s_disk_cache_load_progress = env->GetStaticMethodID(
s_disk_cache_progress_class, "loadProgress",
"(Lorg/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage;II)V");
+ env->DeleteLocalRef(native_library_class);
+
+ // Initialize Cheat
+ const jclass cheat_class = env->FindClass("org/citra/citra_emu/features/cheats/model/Cheat");
+ s_cheat_class = reinterpret_cast(env->NewGlobalRef(cheat_class));
+ s_cheat_pointer = env->GetFieldID(cheat_class, "mPointer", "J");
+ s_cheat_constructor = env->GetMethodID(cheat_class, "", "(J)V");
+ env->DeleteLocalRef(cheat_class);
// Initialize LoadCallbackStage map
const auto to_java_load_callback_stage = [env](const std::string& stage) {
@@ -215,11 +240,12 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
return;
}
- env->DeleteGlobalRef(s_native_library_class);
env->DeleteGlobalRef(s_savestate_info_class);
env->DeleteGlobalRef(s_core_error_class);
env->DeleteGlobalRef(s_disk_cache_progress_class);
env->DeleteGlobalRef(s_load_callback_stage_class);
+ env->DeleteGlobalRef(s_native_library_class);
+ env->DeleteGlobalRef(s_cheat_class);
for (auto& [key, object] : s_java_load_callback_stages) {
env->DeleteGlobalRef(object);
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
index 4b8c89511..87bebed0e 100644
--- a/src/android/app/src/main/jni/id_cache.h
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -12,11 +12,13 @@
namespace IDCache {
JNIEnv* GetEnvForThread();
-jclass GetNativeLibraryClass();
+
jclass GetCoreErrorClass();
jclass GetSavestateInfoClass();
jclass GetDiskCacheProgressClass();
jclass GetDiskCacheLoadCallbackStageClass();
+
+jclass GetNativeLibraryClass();
jmethodID GetOnCoreError();
jmethodID GetDisplayAlertMsg();
jmethodID GetDisplayAlertPrompt();
@@ -28,6 +30,10 @@ jmethodID GetRequestCameraPermission();
jmethodID GetRequestMicPermission();
jmethodID GetDiskCacheLoadProgress();
+jclass GetCheatClass();
+jfieldID GetCheatPointer();
+jmethodID GetCheatConstructor();
+
jobject GetJavaLoadCallbackStage(VideoCore::LoadCallbackStage stage);
} // namespace IDCache
diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 000000000..bdd99f48d
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml
new file mode 100644
index 000000000..9bcf883e1
--- /dev/null
+++ b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/activity_cheats.xml b/src/android/app/src/main/res/layout/activity_cheats.xml
new file mode 100644
index 000000000..b9414ab6d
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_cheats.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_cheat_details.xml b/src/android/app/src/main/res/layout/fragment_cheat_details.xml
new file mode 100644
index 000000000..25b1a268a
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_cheat_details.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/fragment_cheat_list.xml b/src/android/app/src/main/res/layout/fragment_cheat_list.xml
new file mode 100644
index 000000000..679a49c28
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_cheat_list.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/layout/list_item_cheat.xml b/src/android/app/src/main/res/layout/list_item_cheat.xml
new file mode 100644
index 000000000..c0b5f982f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_cheat.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/res/menu/menu_emulation.xml b/src/android/app/src/main/res/menu/menu_emulation.xml
index ea3301d37..b6c0d7cc4 100644
--- a/src/android/app/src/main/res/menu/menu_emulation.xml
+++ b/src/android/app/src/main/res/menu/menu_emulation.xml
@@ -105,6 +105,11 @@
android:title="@string/emulation_show_overlay"
android:checkable="true" />
+
+
- Relative Stick Center
Enable D-Pad Sliding
Open Settings
+ Open Cheats
Landscape Screen Layout
Default
Portrait
@@ -223,4 +224,17 @@
Preparing shaders
Building shaders
+
+
+ Cheats
+ Add Cheat
+ Name
+ Notes
+ Code
+ Edit
+ Delete
+ Are you sure you want to delete \"%1$s\"?
+ Name can\'t be empty
+ Code can\'t be empty
+ Error on line %1$d