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 @@
             </intent-filter>
         </activity>
 
+        <activity
+            android:name="org.citra.citra_emu.features.cheats.ui.CheatsActivity"
+            android:exported="false"
+            android:theme="@style/CitraSettingsBase"
+            android:label="@string/cheats"/>
+
         <service android:name="org.citra.citra_emu.utils.DirectoryInitialization"/>
 
         <provider
diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
index adddcf110..34ab49c96 100644
--- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
+++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java
@@ -30,6 +30,7 @@ import androidx.fragment.app.FragmentActivity;
 import org.citra.citra_emu.CitraApplication;
 import org.citra.citra_emu.NativeLibrary;
 import org.citra.citra_emu.R;
+import org.citra.citra_emu.features.cheats.ui.CheatsActivity;
 import org.citra.citra_emu.features.settings.model.view.InputBindingSetting;
 import org.citra.citra_emu.features.settings.ui.SettingsActivity;
 import org.citra.citra_emu.features.settings.utils.SettingsFile;
@@ -72,6 +73,7 @@ public final class EmulationActivity extends AppCompatActivity {
     public static final int MENU_ACTION_REMOVE_AMIIBO = 14;
     public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15;
     public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16;
+    public static final int MENU_ACTION_OPEN_CHEATS = 17;
 
     public static final int REQUEST_SELECT_AMIIBO = 2;
     private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000;
@@ -110,6 +112,8 @@ public final class EmulationActivity extends AppCompatActivity {
                 EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER);
         buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable,
                 EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE);
+        buttonsActionsMap
+                .append(R.id.menu_emulation_open_cheats, EmulationActivity.MENU_ACTION_OPEN_CHEATS);
     }
 
     private View mDecorView;
@@ -466,11 +470,16 @@ public final class EmulationActivity extends AppCompatActivity {
                 EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled);
                 item.setChecked(isJoystickRelCenterEnabled);
                 break;
+
             case MENU_ACTION_DPAD_SLIDE_ENABLE:
                 final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable();
                 EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled);
                 item.setChecked(isDpadSlideEnabled);
                 break;
+
+            case MENU_ACTION_OPEN_CHEATS:
+                CheatsActivity.launch(this);
+                break;
         }
 
         return true;
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..cb4788cb8
--- /dev/null
+++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java
@@ -0,0 +1,181 @@
+package org.citra.citra_emu.features.cheats.model;
+
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.ViewModel;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+
+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/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 <memory>
+#include <string>
+#include <vector>
+
+#include <jni.h>
+
+#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<Cheats::CheatBase>* CheatFromJava(JNIEnv* env, jobject cheat) {
+    return reinterpret_cast<std::shared_ptr<Cheats::CheatBase>*>(
+        env->GetLongField(cheat, IDCache::GetCheatPointer()));
+}
+
+jobject CheatToJava(JNIEnv* env, std::shared_ptr<Cheats::CheatBase> cheat) {
+    return env->NewObject(
+        IDCache::GetCheatClass(), IDCache::GetCheatConstructor(),
+        reinterpret_cast<jlong>(new std::shared_ptr<Cheats::CheatBase>(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<jboolean>((*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<bool>(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<std::string> 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<Cheats::GatewayCheat>(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 <memory>
+
+#include <jni.h>
+
+namespace Cheats {
+class CheatBase;
+}
+
+std::shared_ptr<Cheats::CheatBase>* CheatFromJava(JNIEnv* env, jobject cheat);
+jobject CheatToJava(JNIEnv* env, std::shared_ptr<Cheats::CheatBase> 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 <utility>
+#include <vector>
+
+#include <jni.h>
+
+#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<jsize>(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<VideoCore::LoadCallbackStage, jobject> 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<jclass>(env->NewGlobalRef(native_library_class));
+    // Initialize misc classes
     s_savestate_info_class = reinterpret_cast<jclass>(
         env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SavestateInfo")));
     s_core_error_class = reinterpret_cast<jclass>(
@@ -159,7 +174,9 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
     s_load_callback_stage_class = reinterpret_cast<jclass>(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<jclass>(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<jclass>(env->NewGlobalRef(cheat_class));
+    s_cheat_pointer = env->GetFieldID(cheat_class, "mPointer", "J");
+    s_cheat_constructor = env->GetMethodID(cheat_class, "<init>", "(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 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
+</vector>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/root"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:focusable="true"
+    android:nextFocusLeft="@id/checkbox">
+
+    <TextView
+        android:id="@+id/text_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textColor="@color/header_text"
+        android:textSize="16sp"
+        android:layout_margin="@dimen/spacing_large"
+        style="@style/TextAppearance.AppCompat.Headline"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/checkbox"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        tools:text="Max Lives after losing 1" />
+
+    <CheckBox
+        android:id="@+id/checkbox"
+        android:layout_width="48dp"
+        android:layout_height="64dp"
+        android:focusable="true"
+        android:gravity="center"
+        android:nextFocusRight="@id/root"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/text_name"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.slidingpanelayout.widget.SlidingPaneLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/sliding_pane_layout"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <androidx.fragment.app.FragmentContainerView
+        android:layout_width="320dp"
+        android:layout_height="match_parent"
+        android:layout_weight="1"
+        android:id="@+id/cheat_list"
+        android:name="org.citra.citra_emu.features.cheats.ui.CheatListFragment" />
+
+    <androidx.fragment.app.FragmentContainerView
+        android:layout_width="320dp"
+        android:layout_height="match_parent"
+        android:layout_weight="1"
+        android:id="@+id/cheat_details"
+        android:name="org.citra.citra_emu.features.cheats.ui.CheatDetailsFragment" />
+
+</androidx.slidingpanelayout.widget.SlidingPaneLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/root"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <ScrollView
+        android:id="@+id/scroll_view"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toTopOf="@id/barrier">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <TextView
+                android:id="@+id/label_name"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                style="@style/TextAppearance.MaterialComponents.Headline5"
+                android:textSize="18sp"
+                android:text="@string/cheats_name"
+                android:layout_margin="@dimen/spacing_large"
+                android:labelFor="@id/edit_name"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:layout_constraintBottom_toTopOf="@id/edit_name" />
+
+            <EditText
+                android:id="@+id/edit_name"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:minHeight="48dp"
+                android:layout_marginHorizontal="@dimen/spacing_large"
+                android:importantForAutofill="no"
+                android:inputType="text"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/label_name"
+                app:layout_constraintBottom_toTopOf="@id/label_notes"
+                tools:text="Max Lives after losing 1" />
+
+            <TextView
+                android:id="@+id/label_notes"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                style="@style/TextAppearance.MaterialComponents.Headline5"
+                android:textSize="18sp"
+                android:text="@string/cheats_notes"
+                android:layout_margin="@dimen/spacing_large"
+                android:labelFor="@id/edit_notes"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/edit_name"
+                app:layout_constraintBottom_toTopOf="@id/edit_notes" />
+
+            <EditText
+                android:id="@+id/edit_notes"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:minHeight="48dp"
+                android:layout_marginHorizontal="@dimen/spacing_large"
+                android:importantForAutofill="no"
+                android:inputType="textMultiLine"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/label_notes"
+                app:layout_constraintBottom_toTopOf="@id/label_code" />
+
+            <TextView
+                android:id="@+id/label_code"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                style="@style/TextAppearance.MaterialComponents.Headline5"
+                android:textSize="18sp"
+                android:text="@string/cheats_code"
+                android:layout_margin="@dimen/spacing_large"
+                android:labelFor="@id/edit_code"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/edit_notes"
+                app:layout_constraintBottom_toTopOf="@id/edit_code" />
+
+            <EditText
+                android:id="@+id/edit_code"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:minHeight="108sp"
+                android:layout_marginHorizontal="@dimen/spacing_large"
+                android:importantForAutofill="no"
+                android:inputType="textMultiLine"
+                android:typeface="monospace"
+                android:gravity="start"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintTop_toBottomOf="@id/label_code"
+                app:layout_constraintBottom_toBottomOf="parent"
+                tools:text="D3000000 00000000\n00138C78 E1C023BE" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+    </ScrollView>
+
+    <androidx.constraintlayout.widget.Barrier
+        android:id="@+id/barrier"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:barrierDirection="top"
+        app:constraint_referenced_ids="button_delete,button_edit,button_cancel,button_ok" />
+
+    <Button
+        android:id="@+id/button_delete"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/spacing_large"
+        android:text="@string/cheats_delete"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/button_edit"
+        app:layout_constraintTop_toBottomOf="@id/barrier"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+    <Button
+        android:id="@+id/button_edit"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/spacing_large"
+        android:text="@string/cheats_edit"
+        app:layout_constraintStart_toEndOf="@id/button_delete"
+        app:layout_constraintEnd_toStartOf="@id/button_cancel"
+        app:layout_constraintTop_toBottomOf="@id/barrier"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+    <Button
+        android:id="@+id/button_cancel"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/spacing_large"
+        android:text="@android:string/cancel"
+        app:layout_constraintStart_toEndOf="@id/button_edit"
+        app:layout_constraintEnd_toStartOf="@id/button_ok"
+        app:layout_constraintTop_toBottomOf="@id/barrier"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+    <Button
+        android:id="@+id/button_ok"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_margin="@dimen/spacing_large"
+        android:text="@android:string/ok"
+        app:layout_constraintStart_toEndOf="@id/button_cancel"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/barrier"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/cheat_list"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+    <com.google.android.material.floatingactionbutton.FloatingActionButton
+        android:id="@+id/fab"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:src="@drawable/ic_add"
+        android:contentDescription="@string/cheats_add"
+        android:layout_margin="16dp"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/root"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:focusable="true"
+    android:nextFocusRight="@id/checkbox">
+
+    <TextView
+        android:id="@+id/text_name"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:textColor="@color/header_text"
+        android:textSize="16sp"
+        android:layout_margin="@dimen/spacing_large"
+        style="@style/TextAppearance.AppCompat.Headline"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintEnd_toStartOf="@id/checkbox"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintBottom_toBottomOf="parent"
+        tools:text="Max Lives after losing 1" />
+
+    <CheckBox
+        android:id="@+id/checkbox"
+        android:layout_width="48dp"
+        android:layout_height="64dp"
+        android:focusable="true"
+        android:gravity="center"
+        android:nextFocusLeft="@id/root"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@id/text_name"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
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" />
 
+    <item
+        android:id="@+id/menu_emulation_open_cheats"
+        app:showAsAction="never"
+        android:title="@string/emulation_open_cheats" />
+
     <item
         android:id="@+id/menu_emulation_open_settings"
         app:showAsAction="never"
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
index 5d8f83182..d1bbe2856 100644
--- a/src/android/app/src/main/res/values/strings.xml
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -160,6 +160,7 @@
     <string name="emulation_control_joystick_rel_center">Relative Stick Center</string>
     <string name="emulation_control_dpad_slide_enable">Enable D-Pad Sliding</string>
     <string name="emulation_open_settings">Open Settings</string>
+    <string name="emulation_open_cheats">Open Cheats</string>
     <string name="emulation_switch_screen_layout">Landscape Screen Layout</string>
     <string name="emulation_screen_layout_landscape">Default</string>
     <string name="emulation_screen_layout_portrait">Portrait</string>
@@ -223,4 +224,17 @@
     <!-- Disk shader cache -->
     <string name="preparing_shaders">Preparing shaders</string>
     <string name="building_shaders">Building shaders</string>
+
+    <!-- Cheats -->
+    <string name="cheats">Cheats</string>
+    <string name="cheats_add">Add Cheat</string>
+    <string name="cheats_name">Name</string>
+    <string name="cheats_notes">Notes</string>
+    <string name="cheats_code">Code</string>
+    <string name="cheats_edit">Edit</string>
+    <string name="cheats_delete">Delete</string>
+    <string name="cheats_delete_confirmation">Are you sure you want to delete \"%1$s\"?</string>
+    <string name="cheats_error_no_name">Name can\'t be empty</string>
+    <string name="cheats_error_no_code_lines">Code can\'t be empty</string>
+    <string name="cheats_error_on_line">Error on line %1$d</string>
 </resources>