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>