From 762ddfd07bd0c24c5fd99c202535d71410f9cc88 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Sun, 17 Dec 2023 20:32:30 -0500 Subject: [PATCH] Android UI Overhaul Part 4/4 (#7235) * android: Rework cheats Reworks cheats to use the navigation component, kotlin, and a tweaked layout for a better tuned look. * android: Convert remaining files to kotlin and add overlay home button * android: Remove Picasso dependency * android: Fix home option layout centering * android: Adjust logo size in-app --- src/android/app/build.gradle.kts | 4 - .../citra/citra_emu/adapters/GameAdapter.kt | 6 +- .../citra/citra_emu/applets/MiiSelector.java | 129 -- .../citra/citra_emu/applets/MiiSelector.kt | 47 + .../citra_emu/applets/SoftwareKeyboard.java | 279 ----- .../citra_emu/applets/SoftwareKeyboard.kt | 152 +++ .../contracts/OpenFileResultContract.java | 24 - .../contracts/OpenFileResultContract.kt | 19 + .../features/cheats/model/Cheat.java | 57 - .../citra_emu/features/cheats/model/Cheat.kt | 48 + .../features/cheats/model/CheatEngine.java | 28 - .../features/cheats/model/CheatEngine.kt | 31 + .../cheats/model/CheatsViewModel.java | 187 --- .../features/cheats/model/CheatsViewModel.kt | 169 +++ .../cheats/ui/CheatDetailsFragment.java | 175 --- .../cheats/ui/CheatDetailsFragment.kt | 193 +++ .../features/cheats/ui/CheatListFragment.java | 71 -- .../features/cheats/ui/CheatListFragment.kt | 143 +++ .../features/cheats/ui/CheatViewHolder.java | 56 - .../features/cheats/ui/CheatsActivity.java | 235 ---- .../features/cheats/ui/CheatsActivity.kt | 63 + .../features/cheats/ui/CheatsAdapter.java | 72 -- .../features/cheats/ui/CheatsAdapter.kt | 69 ++ .../features/cheats/ui/CheatsFragment.kt | 244 ++++ .../model/{view => }/AbstractShortSetting.kt | 4 +- .../model/view/SingleChoiceSetting.kt | 1 + .../model/view/StringSingleChoiceSetting.kt | 1 + .../features/settings/ui/SettingsAdapter.kt | 2 +- .../settings/ui/SettingsFragmentPresenter.kt | 2 +- .../citra_emu/fragments/EmulationFragment.kt | 3 - .../fragments/KeyboardDialogFragment.kt | 115 ++ .../fragments/MiiSelectorDialogFragment.kt | 60 + .../citra/citra_emu/model/CheapDocument.java | 36 - .../citra/citra_emu/model/CheapDocument.kt | 17 + .../citra/citra_emu/overlay/InputOverlay.java | 766 ------------ .../citra/citra_emu/overlay/InputOverlay.kt | 1051 +++++++++++++++++ .../overlay/InputOverlayDrawableButton.java | 159 --- .../overlay/InputOverlayDrawableButton.kt | 128 ++ .../overlay/InputOverlayDrawableDpad.java | 299 ----- .../overlay/InputOverlayDrawableDpad.kt | 262 ++++ .../overlay/InputOverlayDrawableJoystick.java | 267 ----- .../overlay/InputOverlayDrawableJoystick.kt | 238 ++++ .../citra_emu/ui/DividerItemDecoration.java | 130 -- .../ui/TwoPaneOnBackPressedCallback.java | 46 - .../ui/TwoPaneOnBackPressedCallback.kt | 40 + .../org/citra/citra_emu/utils/Action1.java | 5 - .../java/org/citra/citra_emu/utils/BiMap.java | 22 - .../java/org/citra/citra_emu/utils/BiMap.kt | 22 + .../citra_emu/utils/CiaInstallWorker.java | 153 --- .../citra/citra_emu/utils/CiaInstallWorker.kt | 168 +++ .../citra_emu/utils/FileBrowserHelper.java | 50 - .../citra_emu/utils/FileBrowserHelper.kt | 44 + .../citra/citra_emu/utils/InsetsHelper.java | 33 - .../org/citra/citra_emu/utils/InsetsHelper.kt | 25 + .../java/org/citra/citra_emu/utils/Log.java | 42 - .../java/org/citra/citra_emu/utils/Log.kt | 37 + .../citra/citra_emu/utils/PicassoUtils.java | 27 - .../citra_emu/viewholders/GameViewHolder.java | 46 - .../app/src/main/jni/applets/mii_selector.cpp | 9 +- .../app/src/main/jni/applets/swkbd.cpp | 10 +- .../app/src/main/res/drawable/button_home.xml | 16 + .../main/res/drawable/button_home_pressed.xml | 16 + .../main/res/layout-ldrtl/list_item_cheat.xml | 34 +- .../src/main/res/layout/activity_cheats.xml | 66 +- .../src/main/res/layout/card_home_option.xml | 12 +- .../src/main/res/layout/fragment_about.xml | 4 +- .../res/layout/fragment_cheat_details.xml | 286 ++--- .../main/res/layout/fragment_cheat_list.xml | 36 +- .../src/main/res/layout/fragment_cheats.xml | 26 + .../res/layout/fragment_home_settings.xml | 6 +- .../src/main/res/layout/list_item_cheat.xml | 28 +- .../main/res/navigation/cheats_navigation.xml | 19 + .../main/res/navigation/home_navigation.xml | 14 + .../app/src/main/res/values/arrays.xml | 1 + .../app/src/main/res/values/integers.xml | 6 +- .../app/src/main/res/values/strings.xml | 1 + 76 files changed, 3738 insertions(+), 3654 deletions(-) delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsFragment.kt rename src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/{view => }/AbstractShortSetting.kt (62%) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/KeyboardDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/MiiSelectorDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Log.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java create mode 100644 src/android/app/src/main/res/drawable/button_home.xml create mode 100644 src/android/app/src/main/res/drawable/button_home_pressed.xml create mode 100644 src/android/app/src/main/res/layout/fragment_cheats.xml create mode 100644 src/android/app/src/main/res/navigation/cheats_navigation.xml diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index cc24879e9..001dcc546 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -178,10 +178,6 @@ dependencies { implementation("com.google.android.material:material:1.9.0") implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.work:work-runtime:2.8.1") - - // For loading huge screenshots from the disk. - implementation("com.squareup.picasso:picasso:2.71828") - implementation("org.ini4j:ini4j:0.5.4") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation("androidx.navigation:navigation-fragment-ktx:2.7.5") diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt index e84aacb1e..cc0a2b750 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -26,10 +26,9 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.citra.citra_emu.HomeNavigationDirections import org.citra.citra_emu.CitraApplication import org.citra.citra_emu.R -import org.citra.citra_emu.activities.EmulationActivity import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder import org.citra.citra_emu.databinding.CardGameBinding -import org.citra.citra_emu.features.cheats.ui.CheatsActivity +import org.citra.citra_emu.features.cheats.ui.CheatsFragmentDirections import org.citra.citra_emu.model.Game import org.citra.citra_emu.utils.GameIconUtils import org.citra.citra_emu.viewmodel.GamesViewModel @@ -100,7 +99,8 @@ class GameAdapter(private val activity: AppCompatActivity) : .setPositiveButton(android.R.string.ok, null) .show() } else { - CheatsActivity.launch(view.context, holder.game.titleId) + val action = CheatsFragmentDirections.actionGlobalCheatsFragment(holder.game.titleId) + view.findNavController().navigate(action) } return true } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java deleted file mode 100644 index 67f51bc6d..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright 2020 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra.citra_emu.applets; - -import android.app.Activity; -import android.app.Dialog; -import android.content.DialogInterface; -import android.os.Bundle; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.Objects; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -@Keep -public final class MiiSelector { - @Keep - public static class MiiSelectorConfig implements java.io.Serializable { - public boolean enable_cancel_button; - public String title; - public long initially_selected_mii_index; - // List of Miis to display - public String[] mii_names; - } - - public static class MiiSelectorData { - public long return_code; - public int index; - - private MiiSelectorData(long return_code, int index) { - this.return_code = return_code; - this.index = index; - } - } - - public static class MiiSelectorDialogFragment extends DialogFragment { - static MiiSelectorDialogFragment newInstance(MiiSelectorConfig config) { - MiiSelectorDialogFragment frag = new MiiSelectorDialogFragment(); - Bundle args = new Bundle(); - args.putSerializable("config", config); - frag.setArguments(args); - return frag; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Activity emulationActivity = Objects.requireNonNull(getActivity()); - - MiiSelectorConfig config = - Objects.requireNonNull((MiiSelectorConfig) Objects.requireNonNull(getArguments()) - .getSerializable("config")); - - // Note: we intentionally leave out the Standard Mii in the native code so that - // the string can get translated - ArrayList list = new ArrayList<>(); - list.add(emulationActivity.getString(R.string.standard_mii)); - list.addAll(Arrays.asList(config.mii_names)); - - final int initialIndex = config.initially_selected_mii_index < list.size() - ? (int) config.initially_selected_mii_index - : 0; - data.index = initialIndex; - MaterialAlertDialogBuilder builder = - new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(config.title.isEmpty() - ? emulationActivity.getString(R.string.mii_selector) - : config.title) - .setSingleChoiceItems(list.toArray(new String[]{}), initialIndex, - (dialog, which) -> { - data.index = which; - }) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - data.return_code = 0; - synchronized (finishLock) { - finishLock.notifyAll(); - } - }); - if (config.enable_cancel_button) { - builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { - data.return_code = 1; - synchronized (finishLock) { - finishLock.notifyAll(); - } - }); - } - setCancelable(false); - return builder.create(); - } - } - - private static MiiSelectorData data; - private static final Object finishLock = new Object(); - - private static void ExecuteImpl(MiiSelectorConfig config) { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - - data = new MiiSelectorData(0, 0); - - MiiSelectorDialogFragment fragment = MiiSelectorDialogFragment.newInstance(config); - fragment.show(emulationActivity.getSupportFragmentManager(), "mii_selector"); - } - - public static MiiSelectorData Execute(MiiSelectorConfig config) { - NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); - - synchronized (finishLock) { - try { - finishLock.wait(); - } catch (Exception ignored) { - } - } - - return data; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.kt b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.kt new file mode 100644 index 000000000..e7dbfbf66 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.kt @@ -0,0 +1,47 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets + +import androidx.annotation.Keep +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.fragments.MiiSelectorDialogFragment +import java.io.Serializable + +@Keep +object MiiSelector { + lateinit var data: MiiSelectorData + val finishLock = Object() + + private fun ExecuteImpl(config: MiiSelectorConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = MiiSelectorData(0, 0) + val fragment = MiiSelectorDialogFragment.newInstance(config) + fragment.show(emulationActivity!!.supportFragmentManager, "mii_selector") + } + + @JvmStatic + fun Execute(config: MiiSelectorConfig): MiiSelectorData { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { ExecuteImpl(config) } + synchronized(finishLock) { + try { + finishLock.wait() + } catch (ignored: Exception) { + } + } + return data + } + + @Keep + class MiiSelectorConfig : Serializable { + var enableCancelButton = false + var title: String? = null + var initiallySelectedMiiIndex: Long = 0 + + // List of Miis to display + lateinit var miiNames: Array + } + + class MiiSelectorData (var returnCode: Long, var index: Int) +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java deleted file mode 100644 index 77b02a6f0..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java +++ /dev/null @@ -1,279 +0,0 @@ -// Copyright 2020 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra.citra_emu.applets; - -import android.app.Activity; -import android.app.Dialog; -import android.content.DialogInterface; -import android.content.res.Resources; -import android.os.Bundle; -import android.text.InputFilter; -import android.text.Spanned; -import android.util.TypedValue; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.FrameLayout; - -import androidx.annotation.ColorInt; -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.utils.Log; - -import java.util.Objects; - -@Keep -public final class SoftwareKeyboard { - /// Corresponds to Frontend::ButtonConfig - private interface ButtonConfig { - int Single = 0; /// Ok button - int Dual = 1; /// Cancel | Ok buttons - int Triple = 2; /// Cancel | I Forgot | Ok buttons - int None = 3; /// No button (returned by swkbdInputText in special cases) - } - - /// Corresponds to Frontend::ValidationError - public enum ValidationError { - None, - // Button Selection - ButtonOutOfRange, - // Configured Filters - MaxDigitsExceeded, - AtSignNotAllowed, - PercentNotAllowed, - BackslashNotAllowed, - ProfanityNotAllowed, - CallbackFailed, - // Allowed Input Type - FixedLengthRequired, - MaxLengthExceeded, - BlankInputNotAllowed, - EmptyInputNotAllowed, - } - - @Keep - public static class KeyboardConfig implements java.io.Serializable { - public int button_config; - public int max_text_length; - public boolean multiline_mode; /// True if the keyboard accepts multiple lines of input - public String hint_text; /// Displayed in the field as a hint before - @Nullable - public String[] button_text; /// Contains the button text that the caller provides - } - - /// Corresponds to Frontend::KeyboardData - public static class KeyboardData { - public int button; - public String text; - - private KeyboardData(int button, String text) { - this.button = button; - this.text = text; - } - } - - private static class Filter implements InputFilter { - @Override - public CharSequence filter(CharSequence source, int start, int end, Spanned dest, - int dstart, int dend) { - String text = new StringBuilder(dest) - .replace(dstart, dend, source.subSequence(start, end).toString()) - .toString(); - if (ValidateFilters(text) == ValidationError.None) { - return null; // Accept replacement - } - return dest.subSequence(dstart, dend); // Request the subsequence to be unchanged - } - } - - public static class KeyboardDialogFragment extends DialogFragment { - static KeyboardDialogFragment newInstance(KeyboardConfig config) { - KeyboardDialogFragment frag = new KeyboardDialogFragment(); - Bundle args = new Bundle(); - args.putSerializable("config", config); - frag.setArguments(args); - return frag; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Activity emulationActivity = getActivity(); - assert emulationActivity != null; - - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.leftMargin = params.rightMargin = - CitraApplication.Companion.getAppContext().getResources().getDimensionPixelSize( - R.dimen.dialog_margin); - - KeyboardConfig config = Objects.requireNonNull( - (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config")); - - // Set up the input - EditText editText = new EditText(CitraApplication.Companion.getAppContext()); - editText.setHint(config.hint_text); - editText.setSingleLine(!config.multiline_mode); - editText.setLayoutParams(params); - editText.setFilters(new InputFilter[]{ - new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); - - TypedValue typedValue = new TypedValue(); - Resources.Theme theme = requireContext().getTheme(); - theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true); - @ColorInt int color = typedValue.data; - editText.setHintTextColor(color); - editText.setTextColor(color); - - FrameLayout container = new FrameLayout(emulationActivity); - container.addView(editText); - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(R.string.software_keyboard) - .setView(container); - setCancelable(false); - - switch (config.button_config) { - case ButtonConfig.Triple: { - final String text = config.button_text[1].isEmpty() - ? emulationActivity.getString(R.string.i_forgot) - : config.button_text[1]; - builder.setNeutralButton(text, null); - } - // fallthrough - case ButtonConfig.Dual: { - final String text = config.button_text[0].isEmpty() - ? emulationActivity.getString(android.R.string.cancel) - : config.button_text[0]; - builder.setNegativeButton(text, null); - } - // fallthrough - case ButtonConfig.Single: { - final String text = config.button_text[2].isEmpty() - ? emulationActivity.getString(android.R.string.ok) - : config.button_text[2]; - builder.setPositiveButton(text, null); - break; - } - } - - final AlertDialog dialog = builder.create(); - dialog.create(); - if (dialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) { - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener((view) -> { - data.button = config.button_config; - data.text = editText.getText().toString(); - final ValidationError error = ValidateInput(data.text); - if (error != ValidationError.None) { - HandleValidationError(config, error); - return; - } - - dialog.dismiss(); - - synchronized (finishLock) { - finishLock.notifyAll(); - } - }); - } - if (dialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) { - dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener((view) -> { - data.button = 1; - dialog.dismiss(); - synchronized (finishLock) { - finishLock.notifyAll(); - } - }); - } - if (dialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) { - dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((view) -> { - data.button = 0; - dialog.dismiss(); - synchronized (finishLock) { - finishLock.notifyAll(); - } - }); - } - - return dialog; - } - } - - private static KeyboardData data; - private static final Object finishLock = new Object(); - - private static void ExecuteImpl(KeyboardConfig config) { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - - data = new KeyboardData(0, ""); - - KeyboardDialogFragment fragment = KeyboardDialogFragment.newInstance(config); - fragment.show(emulationActivity.getSupportFragmentManager(), "keyboard"); - } - - private static void HandleValidationError(KeyboardConfig config, ValidationError error) { - final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); - String message = ""; - switch (error) { - case FixedLengthRequired: - message = - emulationActivity.getString(R.string.fixed_length_required, config.max_text_length); - break; - case MaxLengthExceeded: - message = - emulationActivity.getString(R.string.max_length_exceeded, config.max_text_length); - break; - case BlankInputNotAllowed: - message = emulationActivity.getString(R.string.blank_input_not_allowed); - break; - case EmptyInputNotAllowed: - message = emulationActivity.getString(R.string.empty_input_not_allowed); - break; - } - - new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(R.string.software_keyboard) - .setMessage(message) - .setPositiveButton(android.R.string.ok, null) - .show(); - } - - public static KeyboardData Execute(KeyboardConfig config) { - if (config.button_config == ButtonConfig.None) { - Log.error("Unexpected button config None"); - return new KeyboardData(0, ""); - } - - NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); - - synchronized (finishLock) { - try { - finishLock.wait(); - } catch (Exception ignored) { - } - } - - return data; - } - - public static void ShowError(String error) { - NativeLibrary.displayAlertMsg( - CitraApplication.Companion.getAppContext().getResources().getString(R.string.software_keyboard), - error, false); - } - - private static native ValidationError ValidateFilters(String text); - - private static native ValidationError ValidateInput(String text); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.kt new file mode 100644 index 000000000..f334366eb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.kt @@ -0,0 +1,152 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets + +import android.text.InputFilter +import android.text.Spanned +import androidx.annotation.Keep +import org.citra.citra_emu.CitraApplication.Companion.appContext +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.fragments.KeyboardDialogFragment +import org.citra.citra_emu.fragments.MessageDialogFragment +import org.citra.citra_emu.utils.Log +import java.io.Serializable + +@Keep +object SoftwareKeyboard { + lateinit var data: KeyboardData + val finishLock = Object() + + private fun ExecuteImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = KeyboardData(0, "") + KeyboardDialogFragment.newInstance(config) + .show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) + } + + fun HandleValidationError(config: KeyboardConfig, error: ValidationError) { + val emulationActivity = NativeLibrary.sEmulationActivity.get()!! + val message: String = when (error) { + ValidationError.FixedLengthRequired -> emulationActivity.getString( + R.string.fixed_length_required, + config.maxTextLength + ) + + ValidationError.MaxLengthExceeded -> + emulationActivity.getString(R.string.max_length_exceeded, config.maxTextLength) + + ValidationError.BlankInputNotAllowed -> + emulationActivity.getString(R.string.blank_input_not_allowed) + + ValidationError.EmptyInputNotAllowed -> + emulationActivity.getString(R.string.empty_input_not_allowed) + + else -> emulationActivity.getString(R.string.invalid_input) + } + + MessageDialogFragment.newInstance(R.string.software_keyboard, message).show( + NativeLibrary.sEmulationActivity.get()!!.supportFragmentManager, + MessageDialogFragment.TAG + ) + } + + @JvmStatic + fun Execute(config: KeyboardConfig): KeyboardData { + if (config.buttonConfig == ButtonConfig.None) { + Log.error("Unexpected button config None") + return KeyboardData(0, "") + } + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { ExecuteImpl(config) } + synchronized(finishLock) { + try { + finishLock.wait() + } catch (ignored: Exception) { + } + } + return data + } + + @JvmStatic + fun ShowError(error: String) { + NativeLibrary.displayAlertMsg( + appContext.resources.getString(R.string.software_keyboard), + error, + false + ) + } + + private external fun ValidateFilters(text: String): ValidationError + external fun ValidateInput(text: String): ValidationError + + /// Corresponds to Frontend::ButtonConfig + interface ButtonConfig { + companion object { + const val Single = 0 /// Ok button + const val Dual = 1 /// Cancel | Ok buttons + const val Triple = 2 /// Cancel | I Forgot | Ok buttons + const val None = 3 /// No button (returned by swkbdInputText in special cases) + } + } + + /// Corresponds to Frontend::ValidationError + enum class ValidationError { + None, + + // Button Selection + ButtonOutOfRange, + + // Configured Filters + MaxDigitsExceeded, + AtSignNotAllowed, + PercentNotAllowed, + BackslashNotAllowed, + ProfanityNotAllowed, + CallbackFailed, + + // Allowed Input Type + FixedLengthRequired, + MaxLengthExceeded, + BlankInputNotAllowed, + EmptyInputNotAllowed + } + + @Keep + class KeyboardConfig : Serializable { + var buttonConfig = 0 + var maxTextLength = 0 + + // True if the keyboard accepts multiple lines of input + var multilineMode = false + + // Displayed in the field as a hint before + var hintText: String? = null + + // Contains the button text that the caller provides + lateinit var buttonText: Array + } + + /// Corresponds to Frontend::KeyboardData + class KeyboardData(var button: Int, var text: String) + class Filter : InputFilter { + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { + val text = StringBuilder(dest) + .replace(dstart, dend, source.subSequence(start, end).toString()) + .toString() + return if (ValidateFilters(text) == ValidationError.None) { + null // Accept replacement + } else { + dest.subSequence(dstart, dend) // Request the subsequence to be unchanged + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java deleted file mode 100644 index cc29088ce..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.citra.citra_emu.contracts; - -import android.content.Context; -import android.content.Intent; -import android.util.Pair; - -import androidx.activity.result.contract.ActivityResultContract; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public class OpenFileResultContract extends ActivityResultContract { - @NonNull - @Override - public Intent createIntent(@NonNull Context context, Boolean allowMultiple) { - return new Intent(Intent.ACTION_OPEN_DOCUMENT) - .setType("application/octet-stream") - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple); - } - - @Override - public Intent parseResult(int i, @Nullable Intent intent) { - return intent; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.kt b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.kt new file mode 100644 index 000000000..401518093 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/contracts/OpenFileResultContract.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.contracts + +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +class OpenFileResultContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Boolean?): Intent { + return Intent(Intent.ACTION_OPEN_DOCUMENT) + .setType("application/octet-stream") + .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): Intent? = intent +} 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 deleted file mode 100644 index 93b026364..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java +++ /dev/null @@ -1,57 +0,0 @@ -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/Cheat.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.kt new file mode 100644 index 000000000..36a508f54 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.kt @@ -0,0 +1,48 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.model + +import androidx.annotation.Keep + +@Keep +class Cheat(@field:Keep private val mPointer: Long) { + private var enabledChangedCallback: Runnable? = null + protected external fun finalize() + + external fun getName(): String + + external fun getNotes(): String + + external fun getCode(): String + + external fun getEnabled(): Boolean + + fun setEnabled(enabled: Boolean) { + setEnabledImpl(enabled) + onEnabledChanged() + } + + private external fun setEnabledImpl(enabled: Boolean) + + fun setEnabledChangedCallback(callback: Runnable) { + enabledChangedCallback = callback + } + + private fun onEnabledChanged() { + enabledChangedCallback?.run() + } + + companion object { + /** + * If the code is valid, returns 0. Otherwise, returns the 1-based index + * for the line containing the error. + */ + @JvmStatic + external fun isValidGatewayCode(code: String): Int + + @JvmStatic + external fun createGatewayCode(name: String, notes: String, code: String): Cheat + } +} 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 deleted file mode 100644 index a1e88a3d3..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.citra.citra_emu.features.cheats.model; - -import androidx.annotation.Keep; - -public class CheatEngine { - @Keep - private final long mPointer; - - @Keep - public CheatEngine(long titleId) { - mPointer = initialize(titleId); - } - - private static native long initialize(long titleId); - - @Override - protected native void finalize(); - - public native Cheat[] getCheats(); - - public native void addCheat(Cheat cheat); - - public native void removeCheat(int index); - - public native void updateCheat(int index, Cheat newCheat); - - public native void saveCheatFile(); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.kt new file mode 100644 index 000000000..8cd10678b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.model + +import androidx.annotation.Keep + +@Keep +class CheatEngine(titleId: Long) { + @Keep + private val mPointer: Long + + init { + mPointer = initialize(titleId) + } + + protected external fun finalize() + + external fun getCheats(): Array + + external fun addCheat(cheat: Cheat?) + external fun removeCheat(index: Int) + external fun updateCheat(index: Int, newCheat: Cheat?) + external fun saveCheatFile() + + companion object { + @JvmStatic + private external fun initialize(titleId: Long): Long + } +} 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 deleted file mode 100644 index dbeb34c21..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java +++ /dev/null @@ -1,187 +0,0 @@ -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 mSelectedCheat = new MutableLiveData<>(null); - private final MutableLiveData mIsAdding = new MutableLiveData<>(false); - private final MutableLiveData mIsEditing = new MutableLiveData<>(false); - - private final MutableLiveData mCheatAddedEvent = new MutableLiveData<>(null); - private final MutableLiveData mCheatChangedEvent = new MutableLiveData<>(null); - private final MutableLiveData mCheatDeletedEvent = new MutableLiveData<>(null); - private final MutableLiveData mOpenDetailsViewEvent = new MutableLiveData<>(false); - - private CheatEngine mCheatEngine; - private Cheat[] mCheats; - private boolean mCheatsNeedSaving = false; - - public void initialize(long titleId) { - mCheatEngine = new CheatEngine(titleId); - load(); - } - - private void load() { - mCheats = mCheatEngine.getCheats(); - - for (int i = 0; i < mCheats.length; i++) { - int position = i; - mCheats[i].setEnabledChangedCallback(() -> { - mCheatsNeedSaving = true; - notifyCheatUpdated(position); - }); - } - } - - public void saveIfNeeded() { - if (mCheatsNeedSaving) { - mCheatEngine.saveCheatFile(); - mCheatsNeedSaving = false; - } - } - - public Cheat[] getCheats() { - return mCheats; - } - - public LiveData getSelectedCheat() { - return mSelectedCheat; - } - - public void setSelectedCheat(Cheat cheat, int position) { - if (mIsEditing.getValue()) { - setIsEditing(false); - } - - mSelectedCheat.setValue(cheat); - mSelectedCheatPosition = position; - } - - public LiveData getIsAdding() { - return mIsAdding; - } - - public LiveData getIsEditing() { - return mIsEditing; - } - - public void setIsEditing(boolean isEditing) { - mIsEditing.setValue(isEditing); - - if (mIsAdding.getValue() && !isEditing) { - mIsAdding.setValue(false); - setSelectedCheat(null, -1); - } - } - - /** - * When a cheat is added, the integer stored in the returned LiveData - * changes to the position of that cheat, then changes back to null. - */ - public LiveData getCheatAddedEvent() { - return mCheatAddedEvent; - } - - private void notifyCheatAdded(int position) { - mCheatAddedEvent.setValue(position); - mCheatAddedEvent.setValue(null); - } - - public void startAddingCheat() { - mSelectedCheat.setValue(null); - mSelectedCheatPosition = -1; - - mIsAdding.setValue(true); - mIsEditing.setValue(true); - } - - public void finishAddingCheat(Cheat cheat) { - if (!mIsAdding.getValue()) { - throw new IllegalStateException(); - } - - mIsAdding.setValue(false); - mIsEditing.setValue(false); - - int position = mCheats.length; - - mCheatEngine.addCheat(cheat); - - mCheatsNeedSaving = true; - load(); - - notifyCheatAdded(position); - setSelectedCheat(mCheats[position], position); - } - - /** - * When a cheat is edited, the integer stored in the returned LiveData - * changes to the position of that cheat, then changes back to null. - */ - public LiveData getCheatUpdatedEvent() { - return mCheatChangedEvent; - } - - /** - * Notifies that an edit has been made to the contents of the cheat at the given position. - */ - private void notifyCheatUpdated(int position) { - mCheatChangedEvent.setValue(position); - mCheatChangedEvent.setValue(null); - } - - public void updateSelectedCheat(Cheat newCheat) { - mCheatEngine.updateCheat(mSelectedCheatPosition, newCheat); - - mCheatsNeedSaving = true; - load(); - - notifyCheatUpdated(mSelectedCheatPosition); - setSelectedCheat(mCheats[mSelectedCheatPosition], mSelectedCheatPosition); - } - - /** - * When a cheat is deleted, the integer stored in the returned LiveData - * changes to the position of that cheat, then changes back to null. - */ - public LiveData getCheatDeletedEvent() { - return mCheatDeletedEvent; - } - - /** - * Notifies that the cheat at the given position has been deleted. - */ - private void notifyCheatDeleted(int position) { - mCheatDeletedEvent.setValue(position); - mCheatDeletedEvent.setValue(null); - } - - public void deleteSelectedCheat() { - int position = mSelectedCheatPosition; - - setSelectedCheat(null, -1); - - mCheatEngine.removeCheat(position); - - mCheatsNeedSaving = true; - load(); - - notifyCheatDeleted(position); - } - - public LiveData getOpenDetailsViewEvent() { - return mOpenDetailsViewEvent; - } - - public void openDetailsView() { - mOpenDetailsViewEvent.setValue(true); - mOpenDetailsViewEvent.setValue(false); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.kt new file mode 100644 index 000000000..48786ad82 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.kt @@ -0,0 +1,169 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.model + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class CheatsViewModel : ViewModel() { + val selectedCheat get() = _selectedCheat.asStateFlow() + private val _selectedCheat = MutableStateFlow(null) + + val isAdding get() = _isAdding.asStateFlow() + private val _isAdding = MutableStateFlow(false) + + val isEditing get() = _isEditing.asStateFlow() + private val _isEditing = MutableStateFlow(false) + + /** + * When a cheat is added, the integer stored in the returned StateFlow + * changes to the position of that cheat, then changes back to null. + */ + val cheatAddedEvent get() = _cheatAddedEvent.asStateFlow() + private val _cheatAddedEvent = MutableStateFlow(null) + + val cheatChangedEvent get() = _cheatChangedEvent.asStateFlow() + private val _cheatChangedEvent = MutableStateFlow(null) + + /** + * When a cheat is deleted, the integer stored in the returned StateFlow + * changes to the position of that cheat, then changes back to null. + */ + val cheatDeletedEvent get() = _cheatDeletedEvent.asStateFlow() + private val _cheatDeletedEvent = MutableStateFlow(null) + + val openDetailsViewEvent get() = _openDetailsViewEvent.asStateFlow() + private val _openDetailsViewEvent = MutableStateFlow(false) + + val closeDetailsViewEvent get() = _closeDetailsViewEvent.asStateFlow() + private val _closeDetailsViewEvent = MutableStateFlow(false) + + val listViewFocusChange get() = _listViewFocusChange.asStateFlow() + private val _listViewFocusChange = MutableStateFlow(false) + + val detailsViewFocusChange get() = _detailsViewFocusChange.asStateFlow() + private val _detailsViewFocusChange = MutableStateFlow(false) + + private var cheatEngine: CheatEngine? = null + lateinit var cheats: Array + private var cheatsNeedSaving = false + private var selectedCheatPosition = -1 + + fun initialize(titleId: Long) { + cheatEngine = CheatEngine(titleId) + load() + } + + private fun load() { + cheats = cheatEngine!!.getCheats() + for (i in cheats.indices) { + cheats[i].setEnabledChangedCallback { + cheatsNeedSaving = true + notifyCheatUpdated(i) + } + } + } + + fun saveIfNeeded() { + if (cheatsNeedSaving) { + cheatEngine!!.saveCheatFile() + cheatsNeedSaving = false + } + } + + fun setSelectedCheat(cheat: Cheat?, position: Int) { + if (isEditing.value) { + setIsEditing(false) + } + _selectedCheat.value = cheat + selectedCheatPosition = position + } + + fun setIsEditing(value: Boolean) { + _isEditing.value = value + if (isAdding.value && !value) { + _isAdding.value = false + setSelectedCheat(null, -1) + } + } + + private fun notifyCheatAdded(position: Int) { + _cheatAddedEvent.value = position + _cheatAddedEvent.value = null + } + + fun startAddingCheat() { + _selectedCheat.value = null + selectedCheatPosition = -1 + _isAdding.value = true + _isEditing.value = true + } + + fun finishAddingCheat(cheat: Cheat?) { + check(isAdding.value) + _isAdding.value = false + _isEditing.value = false + val position = cheats.size + cheatEngine!!.addCheat(cheat) + cheatsNeedSaving = true + load() + notifyCheatAdded(position) + setSelectedCheat(cheats[position], position) + } + + /** + * Notifies that an edit has been made to the contents of the cheat at the given position. + */ + private fun notifyCheatUpdated(position: Int) { + _cheatChangedEvent.value = position + _cheatChangedEvent.value = null + } + + fun updateSelectedCheat(newCheat: Cheat?) { + cheatEngine!!.updateCheat(selectedCheatPosition, newCheat) + cheatsNeedSaving = true + load() + notifyCheatUpdated(selectedCheatPosition) + setSelectedCheat(cheats[selectedCheatPosition], selectedCheatPosition) + } + + /** + * Notifies that the cheat at the given position has been deleted. + */ + private fun notifyCheatDeleted(position: Int) { + _cheatDeletedEvent.value = position + _cheatDeletedEvent.value = null + } + + fun deleteSelectedCheat() { + val position = selectedCheatPosition + setSelectedCheat(null, -1) + cheatEngine!!.removeCheat(position) + cheatsNeedSaving = true + load() + notifyCheatDeleted(position) + } + + fun openDetailsView() { + _openDetailsViewEvent.value = true + _openDetailsViewEvent.value = false + } + + fun closeDetailsView() { + _closeDetailsViewEvent.value = true + _closeDetailsViewEvent.value = false + } + + fun onListViewFocusChanged(changed: Boolean) { + _listViewFocusChange.value = changed + _listViewFocusChange.value = false + } + + fun onDetailsViewFocusChanged(changed: Boolean) { + _detailsViewFocusChange.value = changed + _detailsViewFocusChange.value = 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 deleted file mode 100644 index 83b3430cd..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java +++ /dev/null @@ -1,175 +0,0 @@ -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.fragment.app.Fragment; -import androidx.lifecycle.ViewModelProvider; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -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(); - - new MaterialAlertDialogBuilder(requireContext()) - .setMessage(getString(R.string.cheats_delete_confirmation, name)) - .setPositiveButton(android.R.string.yes, - (dialog, i) -> mViewModel.deleteSelectedCheat()) - .setNegativeButton(android.R.string.no, null) - .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/CheatDetailsFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.kt new file mode 100644 index 000000000..2357335a9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.kt @@ -0,0 +1,193 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.ui + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.FragmentCheatDetailsBinding +import org.citra.citra_emu.features.cheats.model.Cheat +import org.citra.citra_emu.features.cheats.model.CheatsViewModel + +class CheatDetailsFragment : Fragment() { + private val cheatsViewModel: CheatsViewModel by activityViewModels() + + private var _binding: FragmentCheatDetailsBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCheatDetailsBinding.inflate(layoutInflater) + return binding.root + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.selectedCheat.collect { onSelectedCheatUpdated(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.isEditing.collect { onIsEditingUpdated(it) } + } + } + } + binding.buttonDelete.setOnClickListener { onDeleteClicked() } + binding.buttonEdit.setOnClickListener { onEditClicked() } + binding.buttonCancel.setOnClickListener { onCancelClicked() } + binding.buttonOk.setOnClickListener { 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) { _, hasFocus -> + cheatsViewModel.onDetailsViewFocusChanged(hasFocus) + } + + binding.toolbarCheatDetails.setNavigationOnClickListener { + cheatsViewModel.closeDetailsView() + } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + private fun clearEditErrors() { + binding.editName.error = null + binding.editCode.error = null + } + + private fun onDeleteClicked() { + val name = binding.editNameInput.text.toString() + MaterialAlertDialogBuilder(requireContext()) + .setMessage(getString(R.string.cheats_delete_confirmation, name)) + .setPositiveButton( + android.R.string.ok + ) { _: DialogInterface?, _: Int -> cheatsViewModel.deleteSelectedCheat() } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + private fun onEditClicked() { + cheatsViewModel.setIsEditing(true) + binding.buttonOk.requestFocus() + } + + private fun onCancelClicked() { + cheatsViewModel.setIsEditing(false) + onSelectedCheatUpdated(cheatsViewModel.selectedCheat.value) + binding.buttonDelete.requestFocus() + cheatsViewModel.closeDetailsView() + } + + private fun onOkClicked() { + clearEditErrors() + val name = binding.editNameInput.text.toString() + val notes = binding.editNotesInput.text.toString() + val code = binding.editCodeInput.text.toString() + if (name.isEmpty()) { + binding.editName.error = getString(R.string.cheats_error_no_name) + binding.scrollView.smoothScrollTo(0, binding.editNameInput.top) + return + } else if (code.isEmpty()) { + binding.editCode.error = getString(R.string.cheats_error_no_code_lines) + binding.scrollView.smoothScrollTo(0, binding.editCodeInput.bottom) + return + } + val validityResult = Cheat.isValidGatewayCode(code) + if (validityResult != 0) { + binding.editCode.error = getString(R.string.cheats_error_on_line, validityResult) + binding.scrollView.smoothScrollTo(0, binding.editCodeInput.bottom) + return + } + val newCheat = Cheat.createGatewayCode(name, notes, code) + if (cheatsViewModel.isAdding.value == true) { + cheatsViewModel.finishAddingCheat(newCheat) + } else { + cheatsViewModel.updateSelectedCheat(newCheat) + } + binding.buttonEdit.requestFocus() + } + + private fun onSelectedCheatUpdated(cheat: Cheat?) { + clearEditErrors() + val isEditing: Boolean = cheatsViewModel.isEditing.value == true + + // 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) { + binding.editNameInput.setText("") + binding.editNotesInput.setText("") + binding.editCodeInput.setText("") + } else { + binding.editNameInput.setText(cheat.getName()) + binding.editNotesInput.setText(cheat.getNotes()) + binding.editCodeInput.setText(cheat.getCode()) + } + } + } + + private fun onIsEditingUpdated(isEditing: Boolean) { + if (isEditing) { + binding.root.visibility = View.VISIBLE + } + binding.editNameInput.isEnabled = isEditing + binding.editNotesInput.isEnabled = isEditing + binding.editCodeInput.isEnabled = isEditing + + binding.buttonDelete.visibility = if (isEditing) View.GONE else View.VISIBLE + binding.buttonEdit.visibility = if (isEditing) View.GONE else View.VISIBLE + binding.buttonCancel.visibility = if (isEditing) View.VISIBLE else View.GONE + binding.buttonOk.visibility = if (isEditing) View.VISIBLE else View.GONE + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View?, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpAppBar = binding.toolbarCheatDetails.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarCheatDetails.layoutParams = mlpAppBar + + binding.scrollView.updatePadding(left = leftInsets, right = rightInsets) + binding.buttonContainer.updatePadding(left = leftInsets, right = rightInsets) + + windowInsets + } +} 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 deleted file mode 100644 index 552cf796e..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java +++ /dev/null @@ -1,71 +0,0 @@ -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.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -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 { - private RecyclerView mRecyclerView; - private FloatingActionButton mFab; - - @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) { - mRecyclerView = view.findViewById(R.id.cheat_list); - mFab = view.findViewById(R.id.fab); - - CheatsActivity activity = (CheatsActivity) requireActivity(); - CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); - - mRecyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); - mRecyclerView.setLayoutManager(new LinearLayoutManager(activity)); - mRecyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); - - mFab.setOnClickListener(v -> { - viewModel.startAddingCheat(); - viewModel.openDetailsView(); - }); - - setInsets(); - } - - private void setInsets() { - ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> { - Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - v.setPadding(0, 0, 0, insets.bottom + getResources().getDimensionPixelSize(R.dimen.spacing_fab_list)); - - ViewGroup.MarginLayoutParams mlpFab = - (ViewGroup.MarginLayoutParams) mFab.getLayoutParams(); - int fabPadding = getResources().getDimensionPixelSize(R.dimen.spacing_large); - mlpFab.leftMargin = insets.left + fabPadding; - mlpFab.bottomMargin = insets.bottom + fabPadding; - mlpFab.rightMargin = insets.right + fabPadding; - mFab.setLayoutParams(mlpFab); - - return windowInsets; - }); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.kt new file mode 100644 index 000000000..c71ba99fa --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.kt @@ -0,0 +1,143 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.FragmentCheatListBinding +import org.citra.citra_emu.features.cheats.model.CheatsViewModel +import org.citra.citra_emu.ui.main.MainActivity + +class CheatListFragment : Fragment() { + private var _binding: FragmentCheatListBinding? = null + private val binding get() = _binding!! + + private val cheatsViewModel: CheatsViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCheatListBinding.inflate(layoutInflater) + return binding.root + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.cheatList.adapter = CheatsAdapter(requireActivity(), cheatsViewModel) + binding.cheatList.layoutManager = LinearLayoutManager(requireContext()) + binding.cheatList.addItemDecoration( + MaterialDividerItemDecoration( + requireContext(), + MaterialDividerItemDecoration.VERTICAL + ) + ) + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.cheatAddedEvent.collect { position: Int? -> + position?.let { + binding.cheatList.apply { + post { (adapter as CheatsAdapter).notifyItemInserted(it) } + } + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.cheatChangedEvent.collect { position: Int? -> + position?.let { + binding.cheatList.apply { + post { (adapter as CheatsAdapter).notifyItemChanged(it) } + } + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.cheatDeletedEvent.collect { position: Int? -> + position?.let { + binding.cheatList.apply { + post { (adapter as CheatsAdapter).notifyItemRemoved(it) } + } + } + } + } + } + } + + binding.fab.setOnClickListener { + cheatsViewModel.startAddingCheat() + cheatsViewModel.openDetailsView() + } + + binding.toolbarCheatList.setNavigationOnClickListener { + if (requireActivity() is MainActivity) { + view.findNavController().popBackStack() + } else { + requireActivity().finish() + } + } + + setInsets() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpAppBar = binding.toolbarCheatList.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarCheatList.layoutParams = mlpAppBar + + binding.cheatList.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_fab_list) + ) + + val mlpFab = binding.fab.layoutParams as MarginLayoutParams + val fabPadding = resources.getDimensionPixelSize(R.dimen.spacing_large) + mlpFab.leftMargin = leftInsets + fabPadding + mlpFab.bottomMargin = barInsets.bottom + fabPadding + mlpFab.rightMargin = rightInsets + fabPadding + binding.fab.layoutParams = mlpFab + windowInsets + } + } +} 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 deleted file mode 100644 index 8ba8f86e7..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 9446d1ad9..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java +++ /dev/null @@ -1,235 +0,0 @@ -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.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsAnimationCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.lifecycle.ViewModelProvider; -import androidx.slidingpanelayout.widget.SlidingPaneLayout; - -import com.google.android.material.appbar.AppBarLayout; -import com.google.android.material.appbar.MaterialToolbar; - -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; -import org.citra.citra_emu.utils.InsetsHelper; -import org.citra.citra_emu.utils.ThemeUtil; - -import java.util.List; - -public class CheatsActivity extends AppCompatActivity - implements SlidingPaneLayout.PanelSlideListener { - private static String ARG_TITLE_ID = "title_id"; - - private CheatsViewModel mViewModel; - - private SlidingPaneLayout mSlidingPaneLayout; - private View mCheatList; - private View mCheatDetails; - - private View mCheatListLastFocus; - private View mCheatDetailsLastFocus; - - public static void launch(Context context, long titleId) { - Intent intent = new Intent(context, CheatsActivity.class); - intent.putExtra(ARG_TITLE_ID, titleId); - context.startActivity(intent); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - ThemeUtil.INSTANCE.setTheme(this); - super.onCreate(savedInstanceState); - - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - - long titleId = getIntent().getLongExtra(ARG_TITLE_ID, -1); - - mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class); - mViewModel.initialize(titleId); - - setContentView(R.layout.activity_cheats); - - mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout); - mCheatList = findViewById(R.id.cheat_list_container); - mCheatDetails = findViewById(R.id.cheat_details_container); - - 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 - MaterialToolbar toolbar = findViewById(R.id.toolbar_cheats); - setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - - setInsets(); - } - - @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); - } - } - } - - private void setInsets() { - AppBarLayout appBarLayout = findViewById(R.id.appbar_cheats); - ViewCompat.setOnApplyWindowInsetsListener(mSlidingPaneLayout, (v, windowInsets) -> { - Insets barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - Insets keyboardInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()); - - InsetsHelper.insetAppBar(barInsets, appBarLayout); - mSlidingPaneLayout.setPadding(barInsets.left, 0, barInsets.right, 0); - - // Set keyboard insets if the system supports smooth keyboard animations - ViewGroup.MarginLayoutParams mlpDetails = - (ViewGroup.MarginLayoutParams) mCheatDetails.getLayoutParams(); - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { - if (keyboardInsets.bottom > 0) { - mlpDetails.bottomMargin = keyboardInsets.bottom; - } else { - mlpDetails.bottomMargin = barInsets.bottom; - } - } else { - if (mlpDetails.bottomMargin == 0) { - mlpDetails.bottomMargin = barInsets.bottom; - } - } - mCheatDetails.setLayoutParams(mlpDetails); - - return windowInsets; - }); - - // Update the layout for every frame that the keyboard animates in - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { - ViewCompat.setWindowInsetsAnimationCallback(mCheatDetails, - new WindowInsetsAnimationCompat.Callback( - WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) { - int keyboardInsets = 0; - int barInsets = 0; - - @NonNull - @Override - public WindowInsetsCompat onProgress(@NonNull WindowInsetsCompat insets, - @NonNull List runningAnimations) { - ViewGroup.MarginLayoutParams mlpDetails = - (ViewGroup.MarginLayoutParams) mCheatDetails.getLayoutParams(); - keyboardInsets = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom; - barInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom; - mlpDetails.bottomMargin = Math.max(keyboardInsets, barInsets); - mCheatDetails.setLayoutParams(mlpDetails); - return insets; - } - }); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.kt new file mode 100644 index 000000000..f66a8d373 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.kt @@ -0,0 +1,63 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.ui + +import android.os.Bundle +import android.view.View +import android.view.View.OnFocusChangeListener +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.navigation.fragment.NavHostFragment +import com.google.android.material.color.MaterialColors +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.ActivityCheatsBinding +import org.citra.citra_emu.utils.InsetsHelper +import org.citra.citra_emu.utils.ThemeUtil + +class CheatsActivity : AppCompatActivity() { + private lateinit var binding: ActivityCheatsBinding + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeUtil.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivityCheatsBinding.inflate(layoutInflater) + setContentView(binding.root) + + WindowCompat.setDecorFitsSystemWindows(window, false) + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeUtil.getColorWithOpacity( + MaterialColors.getColor( + binding.navigationBarShade, + com.google.android.material.R.attr.colorSurface + ), + ThemeUtil.SYSTEM_BAR_ALPHA + ) + ) + } + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + val navController = navHostFragment.navController + navController.setGraph(R.navigation.cheats_navigation, intent.extras) + } + + companion object { + fun setOnFocusChangeListenerRecursively(view: View, listener: OnFocusChangeListener?) { + view.onFocusChangeListener = listener + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.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 deleted file mode 100644 index 9cb2ce8d8..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.citra.citra_emu.features.cheats.ui; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.cheats.model.Cheat; -import org.citra.citra_emu.features.cheats.model.CheatsViewModel; - -public class CheatsAdapter extends RecyclerView.Adapter { - private final CheatsActivity mActivity; - private final CheatsViewModel mViewModel; - - public CheatsAdapter(CheatsActivity activity, CheatsViewModel viewModel) { - mActivity = activity; - mViewModel = viewModel; - - mViewModel.getCheatAddedEvent().observe(activity, (position) -> { - if (position != null) { - notifyItemInserted(position); - } - }); - - mViewModel.getCheatUpdatedEvent().observe(activity, (position) -> { - if (position != null) { - notifyItemChanged(position); - } - }); - - mViewModel.getCheatDeletedEvent().observe(activity, (position) -> { - if (position != null) { - notifyItemRemoved(position); - } - }); - } - - @NonNull - @Override - public CheatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - - View cheatView = inflater.inflate(R.layout.list_item_cheat, parent, false); - addViewListeners(cheatView); - return new CheatViewHolder(cheatView); - } - - @Override - public void onBindViewHolder(@NonNull CheatViewHolder holder, int position) { - holder.bind(mActivity, getItemAt(position), position); - } - - @Override - public int getItemCount() { - return mViewModel.getCheats().length; - } - - private void addViewListeners(View view) { - // On a portrait phone screen (or other narrow screen), only one of the two panes are shown - // at the same time. If the user is navigating using a d-pad and moves focus to an element - // in the currently hidden pane, we need to manually show that pane. - CheatsActivity.setOnFocusChangeListenerRecursively(view, - (v, hasFocus) -> mActivity.onListViewFocusChange(hasFocus)); - } - - private Cheat getItemAt(int position) { - return mViewModel.getCheats()[position]; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.kt new file mode 100644 index 000000000..960b14f1d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.kt @@ -0,0 +1,69 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CompoundButton +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.RecyclerView +import org.citra.citra_emu.databinding.ListItemCheatBinding +import org.citra.citra_emu.features.cheats.model.Cheat +import org.citra.citra_emu.features.cheats.model.CheatsViewModel + +class CheatsAdapter( + private val activity: FragmentActivity, + private val viewModel: CheatsViewModel +) : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CheatViewHolder { + val binding = + ListItemCheatBinding.inflate(LayoutInflater.from(parent.context), parent, false) + addViewListeners(binding.root) + return CheatViewHolder(binding) + } + + override fun onBindViewHolder(holder: CheatViewHolder, position: Int) = + holder.bind(activity, viewModel.cheats[position], position) + + override fun getItemCount(): Int = viewModel.cheats.size + + private fun 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) { _, hasFocus -> + viewModel.onListViewFocusChanged(hasFocus) + } + } + + inner class CheatViewHolder(private val binding: ListItemCheatBinding) : + RecyclerView.ViewHolder(binding.root), View.OnClickListener, + CompoundButton.OnCheckedChangeListener { + private lateinit var viewModel: CheatsViewModel + private lateinit var cheat: Cheat + private var position = 0 + + fun bind(activity: FragmentActivity, cheat: Cheat, position: Int) { + viewModel = ViewModelProvider(activity)[CheatsViewModel::class.java] + this.cheat = cheat + this.position = position + binding.textName.text = this.cheat.getName() + binding.cheatSwitch.isChecked = this.cheat.getEnabled() + binding.cheatContainer.setOnClickListener(this) + binding.cheatSwitch.setOnCheckedChangeListener(this) + } + + override fun onClick(root: View) { + viewModel.setSelectedCheat(cheat, position) + viewModel.openDetailsView() + } + + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + cheat.setEnabled(isChecked) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsFragment.kt new file mode 100644 index 000000000..0c446cfd8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsFragment.kt @@ -0,0 +1,244 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.features.cheats.ui + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.databinding.FragmentCheatsBinding +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 +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.viewmodel.HomeViewModel + +class CheatsFragment : Fragment(), SlidingPaneLayout.PanelSlideListener { + private var cheatListLastFocus: View? = null + private var cheatDetailsLastFocus: View? = null + + private var _binding: FragmentCheatsBinding? = null + private val binding get() = _binding!! + + private val cheatsViewModel: CheatsViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCheatsBinding.inflate(inflater) + return binding.root + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + cheatsViewModel.initialize(args.titleId) + + cheatListLastFocus = binding.cheatListContainer + cheatDetailsLastFocus = binding.cheatDetailsContainer + binding.slidingPaneLayout.addPanelSlideListener(this) + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + TwoPaneOnBackPressedCallback(binding.slidingPaneLayout) + ) + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.slidingPaneLayout.isOpen) { + binding.slidingPaneLayout.close() + } else { + if (requireActivity() is MainActivity) { + view.findNavController().popBackStack() + } else { + requireActivity().finish() + } + } + } + } + ) + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.selectedCheat.collect { onSelectedCheatChanged(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.isEditing.collect { onIsEditingChanged(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.openDetailsViewEvent.collect { openDetailsView(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.closeDetailsViewEvent.collect { closeDetailsView(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.listViewFocusChange.collect { onListViewFocusChange(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + cheatsViewModel.detailsViewFocusChange.collect { onDetailsViewFocusChange(it) } + } + } + } + + setInsets() + } + + override fun onStop() { + super.onStop() + cheatsViewModel.saveIfNeeded() + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + override fun onPanelSlide(panel: View, slideOffset: Float) {} + override fun onPanelOpened(panel: View) { + val rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL + cheatDetailsLastFocus!!.requestFocus(if (rtl) View.FOCUS_LEFT else View.FOCUS_RIGHT) + } + + override fun onPanelClosed(panel: View) { + val rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL + cheatListLastFocus!!.requestFocus(if (rtl) View.FOCUS_RIGHT else View.FOCUS_LEFT) + } + + private fun onIsEditingChanged(isEditing: Boolean) { + if (isEditing) { + binding.slidingPaneLayout.lockMode = SlidingPaneLayout.LOCK_MODE_UNLOCKED + } + } + + private fun onSelectedCheatChanged(selectedCheat: Cheat?) { + val cheatSelected = selectedCheat != null || cheatsViewModel.isEditing.value!! + if (!cheatSelected && binding.slidingPaneLayout.isOpen) { + binding.slidingPaneLayout.close() + } + binding.slidingPaneLayout.lockMode = + if (cheatSelected) SlidingPaneLayout.LOCK_MODE_UNLOCKED else SlidingPaneLayout.LOCK_MODE_LOCKED_CLOSED + } + + fun onListViewFocusChange(hasFocus: Boolean) { + if (hasFocus) { + cheatListLastFocus = binding.cheatListContainer.findFocus() + if (cheatListLastFocus == null) throw NullPointerException() + binding.slidingPaneLayout.close() + } + } + + fun onDetailsViewFocusChange(hasFocus: Boolean) { + if (hasFocus) { + cheatDetailsLastFocus = binding.cheatDetailsContainer.findFocus() + if (cheatDetailsLastFocus == null) { + throw NullPointerException() + } + binding.slidingPaneLayout.open() + } + } + + private fun openDetailsView(open: Boolean) { + if (open) { + binding.slidingPaneLayout.open() + } + } + + private fun closeDetailsView(close: Boolean) { + if (close) { + binding.slidingPaneLayout.close() + } + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.slidingPaneLayout + ) { _: View?, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val keyboardInsets = windowInsets.getInsets(WindowInsetsCompat.Type.ime()) + + // Set keyboard insets if the system supports smooth keyboard animations + val mlpDetails = binding.cheatDetailsContainer.layoutParams as ViewGroup.MarginLayoutParams + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + if (keyboardInsets.bottom > 0) { + mlpDetails.bottomMargin = keyboardInsets.bottom + } else { + mlpDetails.bottomMargin = barInsets.bottom + } + } else { + if (mlpDetails.bottomMargin == 0) { + mlpDetails.bottomMargin = barInsets.bottom + } + } + binding.cheatDetailsContainer.layoutParams = mlpDetails + windowInsets + } + + // Update the layout for every frame that the keyboard animates in + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ViewCompat.setWindowInsetsAnimationCallback( + binding.cheatDetailsContainer, + object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + var keyboardInsets = 0 + var barInsets = 0 + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: List + ): WindowInsetsCompat { + val mlpDetails = + binding.cheatDetailsContainer.layoutParams as ViewGroup.MarginLayoutParams + keyboardInsets = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + barInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom + mlpDetails.bottomMargin = keyboardInsets.coerceAtLeast(barInsets) + binding.cheatDetailsContainer.layoutParams = mlpDetails + return insets + } + }) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/AbstractShortSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/AbstractShortSetting.kt similarity index 62% rename from src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/AbstractShortSetting.kt rename to src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/AbstractShortSetting.kt index 865ebbdd0..9fafc5410 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/AbstractShortSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/AbstractShortSetting.kt @@ -2,9 +2,7 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. -package org.citra.citra_emu.features.settings.model.view - -import org.citra.citra_emu.features.settings.model.AbstractSetting +package org.citra.citra_emu.features.settings.model interface AbstractShortSetting : AbstractSetting { var short: Short diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.kt index 75c6ec331..f3e316dea 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -6,6 +6,7 @@ package org.citra.citra_emu.features.settings.model.view import org.citra.citra_emu.features.settings.model.AbstractIntSetting import org.citra.citra_emu.features.settings.model.AbstractSetting +import org.citra.citra_emu.features.settings.model.AbstractShortSetting class SingleChoiceSetting( setting: AbstractSetting?, diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.kt index c763e29f5..17e1ae72c 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.kt @@ -5,6 +5,7 @@ package org.citra.citra_emu.features.settings.model.view import org.citra.citra_emu.features.settings.model.AbstractSetting +import org.citra.citra_emu.features.settings.model.AbstractShortSetting import org.citra.citra_emu.features.settings.model.AbstractStringSetting class StringSingleChoiceSetting( diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt index 6d9cb9798..9f314c6dc 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.kt @@ -37,7 +37,7 @@ import org.citra.citra_emu.features.settings.model.AbstractSetting import org.citra.citra_emu.features.settings.model.AbstractStringSetting import org.citra.citra_emu.features.settings.model.FloatSetting import org.citra.citra_emu.features.settings.model.ScaledFloatSetting -import org.citra.citra_emu.features.settings.model.view.AbstractShortSetting +import org.citra.citra_emu.features.settings.model.AbstractShortSetting import org.citra.citra_emu.features.settings.model.view.DateTimeSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting import org.citra.citra_emu.features.settings.model.view.SettingsItem diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt index de0cc1826..534aee626 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -23,7 +23,7 @@ import org.citra.citra_emu.features.settings.model.IntSetting import org.citra.citra_emu.features.settings.model.ScaledFloatSetting import org.citra.citra_emu.features.settings.model.Settings import org.citra.citra_emu.features.settings.model.StringSetting -import org.citra.citra_emu.features.settings.model.view.AbstractShortSetting +import org.citra.citra_emu.features.settings.model.AbstractShortSetting import org.citra.citra_emu.features.settings.model.view.DateTimeSetting import org.citra.citra_emu.features.settings.model.view.HeaderSetting import org.citra.citra_emu.features.settings.model.view.InputBindingSetting diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt index 62c787c1e..66ec4cfbc 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.kt @@ -139,9 +139,6 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback, Choreographer.Fram emulationActivity = requireActivity() as EmulationActivity } - /** - * Initialize the UI and start emulation in here. - */ override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/KeyboardDialogFragment.kt new file mode 100644 index 000000000..b4f581441 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/KeyboardDialogFragment.kt @@ -0,0 +1,115 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R +import org.citra.citra_emu.applets.SoftwareKeyboard +import org.citra.citra_emu.databinding.DialogSoftwareKeyboardBinding +import org.citra.citra_emu.utils.SerializableHelper.serializable + +class KeyboardDialogFragment : DialogFragment() { + private lateinit var config: SoftwareKeyboard.KeyboardConfig + + private var _binding: DialogSoftwareKeyboardBinding? = null + private val binding get() = _binding!! + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogSoftwareKeyboardBinding.inflate(layoutInflater) + + config = requireArguments().serializable(CONFIG)!! + + binding.apply { + editText.hint = config.hintText + editTextInput.isSingleLine = !config.multilineMode + editTextInput.filters = + arrayOf(SoftwareKeyboard.Filter(), InputFilter.LengthFilter(config.maxTextLength)) + } + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.software_keyboard) + .setView(binding.root) + + isCancelable = false + + when (config.buttonConfig) { + SoftwareKeyboard.ButtonConfig.Triple -> { + val negativeText = + config.buttonText[0].ifEmpty { getString(android.R.string.cancel) } + val neutralText = config.buttonText[1].ifEmpty { getString(R.string.i_forgot) } + val positiveText = config.buttonText[2].ifEmpty { getString(android.R.string.ok) } + builder.setNegativeButton(negativeText, null) + .setNeutralButton(neutralText, null) + .setPositiveButton(positiveText, null) + } + + SoftwareKeyboard.ButtonConfig.Dual -> { + val negativeText = + config.buttonText[0].ifEmpty { getString(android.R.string.cancel) } + val positiveText = config.buttonText[2].ifEmpty { getString(android.R.string.ok) } + builder.setNegativeButton(negativeText, null) + .setPositiveButton(positiveText, null) + } + + SoftwareKeyboard.ButtonConfig.Single -> { + val positiveText = config.buttonText[2].ifEmpty { getString(android.R.string.ok) } + builder.setPositiveButton(positiveText, null) + } + } + + // This overrides the default alert dialog behavior to prevent dismissing the keyboard + // dialog while we show an error message + val alertDialog = builder.create() + alertDialog.create() + if (alertDialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) { + alertDialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + SoftwareKeyboard.data.button = config.buttonConfig + SoftwareKeyboard.data.text = binding.editTextInput.text.toString() + val error = SoftwareKeyboard.ValidateInput(SoftwareKeyboard.data.text) + if (error != SoftwareKeyboard.ValidationError.None) { + SoftwareKeyboard.HandleValidationError(config, error) + return@setOnClickListener + } + dismiss() + synchronized(SoftwareKeyboard.finishLock) { SoftwareKeyboard.finishLock.notifyAll() } + } + } + if (alertDialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) { + alertDialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener { + SoftwareKeyboard.data.button = 1 + dismiss() + synchronized(SoftwareKeyboard.finishLock) { SoftwareKeyboard.finishLock.notifyAll() } + } + } + if (alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) { + alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener { + SoftwareKeyboard.data.button = 0 + dismiss() + synchronized(SoftwareKeyboard.finishLock) { SoftwareKeyboard.finishLock.notifyAll() } + } + } + + return alertDialog + } + + companion object { + const val TAG = "KeyboardDialogFragment" + + const val CONFIG = "config" + + fun newInstance(config: SoftwareKeyboard.KeyboardConfig): KeyboardDialogFragment { + val frag = KeyboardDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/MiiSelectorDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MiiSelectorDialogFragment.kt new file mode 100644 index 000000000..fa1e1ae99 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MiiSelectorDialogFragment.kt @@ -0,0 +1,60 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R +import org.citra.citra_emu.applets.MiiSelector +import org.citra.citra_emu.utils.SerializableHelper.serializable + +class MiiSelectorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val config = requireArguments().serializable(CONFIG)!! + + // Note: we intentionally leave out the Standard Mii in the native code so that + // the string can get translated + val list = mutableListOf() + list.add(getString(R.string.standard_mii)) + list.addAll(config.miiNames) + val initialIndex = + if (config.initiallySelectedMiiIndex < list.size) config.initiallySelectedMiiIndex.toInt() else 0 + MiiSelector.data.index = initialIndex + val builder = MaterialAlertDialogBuilder(requireActivity()) + .setTitle(if (config.title!!.isEmpty()) getString(R.string.mii_selector) else config.title) + .setSingleChoiceItems(list.toTypedArray(), initialIndex) { _: DialogInterface?, which: Int -> + MiiSelector.data.index = which + } + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + MiiSelector.data.returnCode = 0 + synchronized(MiiSelector.finishLock) { MiiSelector.finishLock.notifyAll() } + } + if (config.enableCancelButton) { + builder.setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> + MiiSelector.data.returnCode = 1 + synchronized(MiiSelector.finishLock) { MiiSelector.finishLock.notifyAll() } + } + } + isCancelable = false + return builder.create() + } + + companion object { + const val TAG = "MiiSelectorDialogFragment" + + const val CONFIG = "config" + + fun newInstance(config: MiiSelector.MiiSelectorConfig): MiiSelectorDialogFragment { + val frag = MiiSelectorDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java deleted file mode 100644 index 743e1d842..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.citra.citra_emu.model; - -import android.net.Uri; -import android.provider.DocumentsContract; - -/** - * A struct that is much more "cheaper" than DocumentFile. - * Only contains the information we needed. - */ -public class CheapDocument { - private final String filename; - private final Uri uri; - private final String mimeType; - - public CheapDocument(String filename, String mimeType, Uri uri) { - this.filename = filename; - this.mimeType = mimeType; - this.uri = uri; - } - - public String getFilename() { - return filename; - } - - public Uri getUri() { - return uri; - } - - public String getMimeType() { - return mimeType; - } - - public boolean isDirectory() { - return mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.kt new file mode 100644 index 000000000..d3a9490d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/CheapDocument.kt @@ -0,0 +1,17 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +import android.net.Uri +import android.provider.DocumentsContract + +/** + * A struct that is much more "cheaper" than DocumentFile. + * Only contains the information we needed. + */ +class CheapDocument(val filename: String, val mimeType: String, val uri: Uri) { + val isDirectory: Boolean + get() = mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java deleted file mode 100644 index df41beb4e..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java +++ /dev/null @@ -1,766 +0,0 @@ -/** - * Copyright 2013 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.overlay; - -import android.app.Activity; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.preference.PreferenceManager; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.view.Display; -import android.view.MotionEvent; -import android.view.SurfaceView; -import android.view.View; -import android.view.View.OnTouchListener; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.NativeLibrary.ButtonState; -import org.citra.citra_emu.NativeLibrary.ButtonType; -import org.citra.citra_emu.R; -import org.citra.citra_emu.utils.EmulationMenuSettings; - -import java.util.HashSet; -import java.util.Set; - -/** - * Draws the interactive input overlay on top of the - * {@link SurfaceView} that is rendering emulation. - */ -public final class InputOverlay extends SurfaceView implements OnTouchListener { - private final Set overlayButtons = new HashSet<>(); - private final Set overlayDpads = new HashSet<>(); - private final Set overlayJoysticks = new HashSet<>(); - - private boolean mIsInEditMode = false; - private InputOverlayDrawableButton mButtonBeingConfigured; - private InputOverlayDrawableDpad mDpadBeingConfigured; - private InputOverlayDrawableJoystick mJoystickBeingConfigured; - - private SharedPreferences mPreferences; - - // Stores the ID of the pointer that interacted with the 3DS touchscreen. - private int mTouchscreenPointerId = -1; - - /** - * Constructor - * - * @param context The current {@link Context}. - * @param attrs {@link AttributeSet} for parsing XML attributes. - */ - public InputOverlay(Context context, AttributeSet attrs) { - super(context, attrs); - - mPreferences = PreferenceManager.getDefaultSharedPreferences(getContext()); - if (!mPreferences.getBoolean("OverlayInit", false)) { - defaultOverlay(); - } - - // Reset 3ds touchscreen pointer ID - mTouchscreenPointerId = -1; - - // Load the controls. - refreshControls(); - - // Set the on touch listener. - setOnTouchListener(this); - - // Force draw - setWillNotDraw(false); - - // Request focus for the overlay so it has priority on presses. - requestFocus(); - } - - /** - * Resizes a {@link Bitmap} by a given scale factor - * - * @param context The current {@link Context} - * @param bitmap The {@link Bitmap} to scale. - * @param scale The scale factor for the bitmap. - * @return The scaled {@link Bitmap} - */ - public static Bitmap resizeBitmap(Context context, Bitmap bitmap, float scale) { - // Determine the button size based on the smaller screen dimension. - // This makes sure the buttons are the same size in both portrait and landscape. - DisplayMetrics dm = context.getResources().getDisplayMetrics(); - int minDimension = Math.min(dm.widthPixels, dm.heightPixels); - - return Bitmap.createScaledBitmap(bitmap, - (int) (minDimension * scale), - (int) (minDimension * scale), - true); - } - - /** - * Initializes an InputOverlayDrawableButton, given by resId, with all of the - * parameters set for it to be properly shown on the InputOverlay. - *

- * This works due to the way the X and Y coordinates are stored within - * the {@link SharedPreferences}. - *

- * In the input overlay configuration menu, - * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). - * the X and Y coordinates of the button at the END of its touch event - * (when you remove your finger/stylus from the touchscreen) are then stored - * within a SharedPreferences instance so that those values can be retrieved here. - *

- * This has a few benefits over the conventional way of storing the values - * (ie. within the Citra ini file). - *

    - *
  • No native calls
  • - *
  • Keeps Android-only values inside the Android environment
  • - *
- *

- * Technically no modifications should need to be performed on the returned - * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait - * for Android to call the onDraw method. - * - * @param context The current {@link Context}. - * @param defaultResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Default State). - * @param pressedResId The resource ID of the {@link Drawable} to get the {@link Bitmap} of (Pressed State). - * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. - * @return An {@link InputOverlayDrawableButton} with the correct drawing bounds set. - */ - private static InputOverlayDrawableButton initializeOverlayButton(Context context, - int defaultResId, int pressedResId, int buttonId, String orientation) { - // Resources handle for fetching the initial Drawable resource. - final Resources res = context.getResources(); - - // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton. - final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Decide scale based on button ID and user preference - float scale; - - switch (buttonId) { - case ButtonType.BUTTON_HOME: - case ButtonType.BUTTON_START: - case ButtonType.BUTTON_SELECT: - scale = 0.08f; - break; - case ButtonType.TRIGGER_L: - case ButtonType.TRIGGER_R: - case ButtonType.BUTTON_ZL: - case ButtonType.BUTTON_ZR: - scale = 0.18f; - break; - default: - scale = 0.11f; - break; - } - - scale *= (sPrefs.getInt("controlScale", 50) + 50); - scale /= 100; - - // Initialize the InputOverlayDrawableButton. - final Bitmap defaultStateBitmap = - resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale); - final Bitmap pressedStateBitmap = - resizeBitmap(context, BitmapFactory.decodeResource(res, pressedResId), scale); - final InputOverlayDrawableButton overlayDrawable = - new InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId); - - // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. - // These were set in the input overlay configuration menu. - String xKey; - String yKey; - - xKey = buttonId + orientation + "-X"; - yKey = buttonId + orientation + "-Y"; - - int drawableX = (int) sPrefs.getFloat(xKey, 0f); - int drawableY = (int) sPrefs.getFloat(yKey, 0f); - - int width = overlayDrawable.getWidth(); - int height = overlayDrawable.getHeight(); - - // Now set the bounds for the InputOverlayDrawableButton. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. - overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height); - - // Need to set the image's position - overlayDrawable.setPosition(drawableX, drawableY); - - return overlayDrawable; - } - - /** - * Initializes an {@link InputOverlayDrawableDpad} - * - * @param context The current {@link Context}. - * @param defaultResId The {@link Bitmap} resource ID of the default sate. - * @param pressedOneDirectionResId The {@link Bitmap} resource ID of the pressed sate in one direction. - * @param pressedTwoDirectionsResId The {@link Bitmap} resource ID of the pressed sate in two directions. - * @param buttonUp Identifier for the up button. - * @param buttonDown Identifier for the down button. - * @param buttonLeft Identifier for the left button. - * @param buttonRight Identifier for the right button. - * @return the initialized {@link InputOverlayDrawableDpad} - */ - private static InputOverlayDrawableDpad initializeOverlayDpad(Context context, - int defaultResId, - int pressedOneDirectionResId, - int pressedTwoDirectionsResId, - int buttonUp, - int buttonDown, - int buttonLeft, - int buttonRight, - String orientation) { - // Resources handle for fetching the initial Drawable resource. - final Resources res = context.getResources(); - - // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad. - final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Decide scale based on button ID and user preference - float scale = 0.22f; - - scale *= (sPrefs.getInt("controlScale", 50) + 50); - scale /= 100; - - // Initialize the InputOverlayDrawableDpad. - final Bitmap defaultStateBitmap = - resizeBitmap(context, BitmapFactory.decodeResource(res, defaultResId), scale); - final Bitmap pressedOneDirectionStateBitmap = - resizeBitmap(context, BitmapFactory.decodeResource(res, pressedOneDirectionResId), - scale); - final Bitmap pressedTwoDirectionsStateBitmap = - resizeBitmap(context, BitmapFactory.decodeResource(res, pressedTwoDirectionsResId), - scale); - final InputOverlayDrawableDpad overlayDrawable = - new InputOverlayDrawableDpad(res, defaultStateBitmap, - pressedOneDirectionStateBitmap, pressedTwoDirectionsStateBitmap, - buttonUp, buttonDown, buttonLeft, buttonRight); - - // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. - // These were set in the input overlay configuration menu. - int drawableX = (int) sPrefs.getFloat(buttonUp + orientation + "-X", 0f); - int drawableY = (int) sPrefs.getFloat(buttonUp + orientation + "-Y", 0f); - - int width = overlayDrawable.getWidth(); - int height = overlayDrawable.getHeight(); - - // Now set the bounds for the InputOverlayDrawableDpad. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. - overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height); - - // Need to set the image's position - overlayDrawable.setPosition(drawableX, drawableY); - - return overlayDrawable; - } - - /** - * Initializes an {@link InputOverlayDrawableJoystick} - * - * @param context The current {@link Context} - * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). - * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). - * @param pressedResInner Resource ID for the pressed inner image of the joystick. - * @param joystick Identifier for which joystick this is. - * @return the initialized {@link InputOverlayDrawableJoystick}. - */ - private static InputOverlayDrawableJoystick initializeOverlayJoystick(Context context, - int resOuter, int defaultResInner, int pressedResInner, int joystick, String orientation) { - // Resources handle for fetching the initial Drawable resource. - final Resources res = context.getResources(); - - // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick. - final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(context); - - // Decide scale based on user preference - float scale = 0.275f; - scale *= (sPrefs.getInt("controlScale", 50) + 50); - scale /= 100; - - // Initialize the InputOverlayDrawableJoystick. - final Bitmap bitmapOuter = - resizeBitmap(context, BitmapFactory.decodeResource(res, resOuter), scale); - final Bitmap bitmapInnerDefault = BitmapFactory.decodeResource(res, defaultResInner); - final Bitmap bitmapInnerPressed = BitmapFactory.decodeResource(res, pressedResInner); - - // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. - // These were set in the input overlay configuration menu. - int drawableX = (int) sPrefs.getFloat(joystick + orientation + "-X", 0f); - int drawableY = (int) sPrefs.getFloat(joystick + orientation + "-Y", 0f); - - // Decide inner scale based on joystick ID - float outerScale = 1.f; - if (joystick == ButtonType.STICK_C) { - outerScale = 2.f; - } - - // Now set the bounds for the InputOverlayDrawableJoystick. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. - int outerSize = bitmapOuter.getWidth(); - Rect outerRect = new Rect(drawableX, drawableY, drawableX + (int) (outerSize / outerScale), drawableY + (int) (outerSize / outerScale)); - Rect innerRect = new Rect(0, 0, (int) (outerSize / outerScale), (int) (outerSize / outerScale)); - - // Send the drawableId to the joystick so it can be referenced when saving control position. - final InputOverlayDrawableJoystick overlayDrawable - = new InputOverlayDrawableJoystick(res, bitmapOuter, - bitmapInnerDefault, bitmapInnerPressed, - outerRect, innerRect, joystick); - - // Need to set the image's position - overlayDrawable.setPosition(drawableX, drawableY); - - return overlayDrawable; - } - - @Override - public void draw(Canvas canvas) { - super.draw(canvas); - - for (InputOverlayDrawableButton button : overlayButtons) { - button.draw(canvas); - } - - for (InputOverlayDrawableDpad dpad : overlayDpads) { - dpad.draw(canvas); - } - - for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { - joystick.draw(canvas); - } - } - - @Override - public boolean onTouch(View v, MotionEvent event) { - if (isInEditMode()) { - return onTouchWhileEditing(event); - } - boolean shouldUpdateView = false; - for (InputOverlayDrawableButton button : overlayButtons) { - if (!button.updateStatus(event)) { - continue; - } - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus()); - shouldUpdateView = true; - } - - for (InputOverlayDrawableDpad dpad : overlayDpads) { - if (!dpad.updateStatus(event, EmulationMenuSettings.INSTANCE.getDpadSlide())) { - continue; - } - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus()); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus()); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus()); - NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus()); - shouldUpdateView = true; - } - - for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { - if (!joystick.updateStatus(event)) { - continue; - } - int axisID = joystick.getJoystickId(); - NativeLibrary.INSTANCE - .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis()); - shouldUpdateView = true; - } - - if (shouldUpdateView) { - invalidate(); - } - - if (!mPreferences.getBoolean("isTouchEnabled", true)) { - return true; - } - - int pointerIndex = event.getActionIndex(); - int xPosition = (int) event.getX(pointerIndex); - int yPosition = (int) event.getY(pointerIndex); - int pointerId = event.getPointerId(pointerIndex); - int motionEvent = event.getAction() & MotionEvent.ACTION_MASK; - boolean isActionDown = motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN; - boolean isActionMove = motionEvent == MotionEvent.ACTION_MOVE; - boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP; - - if (isActionDown && !isTouchInputConsumed(pointerId)) { - NativeLibrary.INSTANCE.onTouchEvent(xPosition, yPosition, true); - } - - if (isActionMove) { - for (int i = 0; i < event.getPointerCount(); i++) { - int fingerId = event.getPointerId(i); - if (isTouchInputConsumed(fingerId)) { - continue; - } - NativeLibrary.INSTANCE.onTouchMoved(xPosition, yPosition); - } - } - - if (isActionUp && !isTouchInputConsumed(pointerId)) { - NativeLibrary.INSTANCE.onTouchEvent(0, 0, false); - } - - return true; - } - - private boolean isTouchInputConsumed(int trackId) { - for (InputOverlayDrawableButton button : overlayButtons) { - if (button.getTrackId() == trackId) { - return true; - } - } - for (InputOverlayDrawableDpad dpad : overlayDpads) { - if (dpad.getTrackId() == trackId) { - return true; - } - } - for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { - if (joystick.getTrackId() == trackId) { - return true; - } - } - return false; - } - - public boolean onTouchWhileEditing(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int fingerPositionX = (int) event.getX(pointerIndex); - int fingerPositionY = (int) event.getY(pointerIndex); - - String orientation = - getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ? - "-Portrait" : ""; - - // Maybe combine Button and Joystick as subclasses of the same parent? - // Or maybe create an interface like IMoveableHUDControl? - - for (InputOverlayDrawableButton button : overlayButtons) { - // Determine the button state to apply based on the MotionEvent action flag. - switch (event.getAction() & MotionEvent.ACTION_MASK) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - // If no button is being moved now, remember the currently touched button to move. - if (mButtonBeingConfigured == null && - button.getBounds().contains(fingerPositionX, fingerPositionY)) { - mButtonBeingConfigured = button; - mButtonBeingConfigured.onConfigureTouch(event); - } - break; - case MotionEvent.ACTION_MOVE: - if (mButtonBeingConfigured != null) { - mButtonBeingConfigured.onConfigureTouch(event); - invalidate(); - return true; - } - break; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - if (mButtonBeingConfigured == button) { - // Persist button position by saving new place. - saveControlPosition(mButtonBeingConfigured.getId(), - mButtonBeingConfigured.getBounds().left, - mButtonBeingConfigured.getBounds().top, orientation); - mButtonBeingConfigured = null; - } - break; - } - } - - for (InputOverlayDrawableDpad dpad : overlayDpads) { - // Determine the button state to apply based on the MotionEvent action flag. - switch (event.getAction() & MotionEvent.ACTION_MASK) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - // If no button is being moved now, remember the currently touched button to move. - if (mButtonBeingConfigured == null && - dpad.getBounds().contains(fingerPositionX, fingerPositionY)) { - mDpadBeingConfigured = dpad; - mDpadBeingConfigured.onConfigureTouch(event); - } - break; - case MotionEvent.ACTION_MOVE: - if (mDpadBeingConfigured != null) { - mDpadBeingConfigured.onConfigureTouch(event); - invalidate(); - return true; - } - break; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - if (mDpadBeingConfigured == dpad) { - // Persist button position by saving new place. - saveControlPosition(mDpadBeingConfigured.getUpId(), - mDpadBeingConfigured.getBounds().left, mDpadBeingConfigured.getBounds().top, - orientation); - mDpadBeingConfigured = null; - } - break; - } - } - - for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_POINTER_DOWN: - if (mJoystickBeingConfigured == null && - joystick.getBounds().contains(fingerPositionX, fingerPositionY)) { - mJoystickBeingConfigured = joystick; - mJoystickBeingConfigured.onConfigureTouch(event); - } - break; - case MotionEvent.ACTION_MOVE: - if (mJoystickBeingConfigured != null) { - mJoystickBeingConfigured.onConfigureTouch(event); - invalidate(); - } - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_POINTER_UP: - if (mJoystickBeingConfigured != null) { - saveControlPosition(mJoystickBeingConfigured.getJoystickId(), - mJoystickBeingConfigured.getBounds().left, - mJoystickBeingConfigured.getBounds().top, orientation); - mJoystickBeingConfigured = null; - } - break; - } - } - - return true; - } - - private void addOverlayControls(String orientation) { - if (mPreferences.getBoolean("buttonToggle0", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_a, - R.drawable.button_a_pressed, ButtonType.BUTTON_A, orientation)); - } - if (mPreferences.getBoolean("buttonToggle1", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_b, - R.drawable.button_b_pressed, ButtonType.BUTTON_B, orientation)); - } - if (mPreferences.getBoolean("buttonToggle2", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_x, - R.drawable.button_x_pressed, ButtonType.BUTTON_X, orientation)); - } - if (mPreferences.getBoolean("buttonToggle3", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_y, - R.drawable.button_y_pressed, ButtonType.BUTTON_Y, orientation)); - } - if (mPreferences.getBoolean("buttonToggle4", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_l, - R.drawable.button_l_pressed, ButtonType.TRIGGER_L, orientation)); - } - if (mPreferences.getBoolean("buttonToggle5", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_r, - R.drawable.button_r_pressed, ButtonType.TRIGGER_R, orientation)); - } - if (mPreferences.getBoolean("buttonToggle6", false)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zl, - R.drawable.button_zl_pressed, ButtonType.BUTTON_ZL, orientation)); - } - if (mPreferences.getBoolean("buttonToggle7", false)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_zr, - R.drawable.button_zr_pressed, ButtonType.BUTTON_ZR, orientation)); - } - if (mPreferences.getBoolean("buttonToggle8", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_start, - R.drawable.button_start_pressed, ButtonType.BUTTON_START, orientation)); - } - if (mPreferences.getBoolean("buttonToggle9", true)) { - overlayButtons.add(initializeOverlayButton(getContext(), R.drawable.button_select, - R.drawable.button_select_pressed, ButtonType.BUTTON_SELECT, orientation)); - } - if (mPreferences.getBoolean("buttonToggle10", true)) { - overlayDpads.add(initializeOverlayDpad(getContext(), R.drawable.dpad, - R.drawable.dpad_pressed_one_direction, - R.drawable.dpad_pressed_two_directions, - ButtonType.DPAD_UP, ButtonType.DPAD_DOWN, - ButtonType.DPAD_LEFT, ButtonType.DPAD_RIGHT, orientation)); - } - if (mPreferences.getBoolean("buttonToggle11", true)) { - overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_main_range, - R.drawable.stick_main, R.drawable.stick_main_pressed, - ButtonType.STICK_LEFT, orientation)); - } - if (mPreferences.getBoolean("buttonToggle12", false)) { - overlayJoysticks.add(initializeOverlayJoystick(getContext(), R.drawable.stick_c_range, - R.drawable.stick_c, R.drawable.stick_c_pressed, ButtonType.STICK_C, orientation)); - } - } - - public void refreshControls() { - // Remove all the overlay buttons from the HashSet. - overlayButtons.clear(); - overlayDpads.clear(); - overlayJoysticks.clear(); - - String orientation = - getResources().getConfiguration().orientation == Configuration.ORIENTATION_PORTRAIT ? - "-Portrait" : ""; - - // Add all the enabled overlay items back to the HashSet. - if (EmulationMenuSettings.INSTANCE.getShowOverlay()) { - addOverlayControls(orientation); - } - - invalidate(); - } - - private void saveControlPosition(int sharedPrefsId, int x, int y, String orientation) { - final SharedPreferences sPrefs = PreferenceManager.getDefaultSharedPreferences(getContext()); - SharedPreferences.Editor sPrefsEditor = sPrefs.edit(); - sPrefsEditor.putFloat(sharedPrefsId + orientation + "-X", x); - sPrefsEditor.putFloat(sharedPrefsId + orientation + "-Y", y); - sPrefsEditor.apply(); - } - - public void setIsInEditMode(boolean isInEditMode) { - mIsInEditMode = isInEditMode; - } - - private void defaultOverlay() { - if (!mPreferences.getBoolean("OverlayInit", false)) { - // It's possible that a user has created their overlay before this was added - // Only change the overlay if the 'A' button is not in the upper corner. - if (mPreferences.getFloat(ButtonType.BUTTON_A + "-X", 0f) == 0f) { - defaultOverlayLandscape(); - } - if (mPreferences.getFloat(ButtonType.BUTTON_A + "-Portrait" + "-X", 0f) == 0f) { - defaultOverlayPortrait(); - } - } - - SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); - sPrefsEditor.putBoolean("OverlayInit", true); - sPrefsEditor.apply(); - } - - public void resetButtonPlacement() { - boolean isLandscape = - getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; - - if (isLandscape) { - defaultOverlayLandscape(); - } else { - defaultOverlayPortrait(); - } - - refreshControls(); - } - - private void defaultOverlayLandscape() { - SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); - // Get screen size - Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay(); - DisplayMetrics outMetrics = new DisplayMetrics(); - display.getMetrics(outMetrics); - float maxX = outMetrics.heightPixels; - float maxY = outMetrics.widthPixels; - // Height and width changes depending on orientation. Use the larger value for height. - if (maxY > maxX) { - float tmp = maxX; - maxX = maxY; - maxY = tmp; - } - Resources res = getResources(); - - // Each value is a percent from max X/Y stored as an int. Have to bring that value down - // to a decimal before multiplying by MAX X/Y. - sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_A + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_B + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_X + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_Y + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.DPAD_UP + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_L + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_R + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_START + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.STICK_C + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.STICK_C + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.STICK_LEFT + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_Y) / 1000) * maxY)); - - // We want to commit right away, otherwise the overlay could load before this is saved. - sPrefsEditor.commit(); - } - - private void defaultOverlayPortrait() { - SharedPreferences.Editor sPrefsEditor = mPreferences.edit(); - // Get screen size - Display display = ((Activity) getContext()).getWindowManager().getDefaultDisplay(); - DisplayMetrics outMetrics = new DisplayMetrics(); - display.getMetrics(outMetrics); - float maxX = outMetrics.heightPixels; - float maxY = outMetrics.widthPixels; - // Height and width changes depending on orientation. Use the larger value for height. - if (maxY < maxX) { - float tmp = maxX; - maxX = maxY; - maxY = tmp; - } - Resources res = getResources(); - String portrait = "-Portrait"; - - // Each value is a percent from max X/Y stored as an int. Have to bring that value down - // to a decimal before multiplying by MAX X/Y. - sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_A + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_B + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_X + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_Y + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZL + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_ZR + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.DPAD_UP + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_L + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.TRIGGER_R + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_START + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_SELECT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.BUTTON_HOME + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.STICK_C + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_Y) / 1000) * maxY)); - sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-X", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_X) / 1000) * maxX)); - sPrefsEditor.putFloat(ButtonType.STICK_LEFT + portrait + "-Y", (((float) res.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_Y) / 1000) * maxY)); - - // We want to commit right away, otherwise the overlay could load before this is saved. - sPrefsEditor.commit(); - } - - public boolean isInEditMode() { - return mIsInEditMode; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt new file mode 100644 index 000000000..deb718c7e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.kt @@ -0,0 +1,1051 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.overlay + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.VectorDrawable +import android.util.AttributeSet +import android.util.DisplayMetrics +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.utils.EmulationMenuSettings +import java.lang.NullPointerException +import kotlin.math.min + +/** + * Draws the interactive input overlay on top of the + * [SurfaceView] that is rendering emulation. + * + * @param context The current [Context]. + * @param attrs [AttributeSet] for parsing XML attributes. + */ +class InputOverlay(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs), + OnTouchListener { + private val overlayButtons: MutableSet = HashSet() + private val overlayDpads: MutableSet = HashSet() + private val overlayJoysticks: MutableSet = HashSet() + private var isInEditMode = false + private var buttonBeingConfigured: InputOverlayDrawableButton? = null + private var dpadBeingConfigured: InputOverlayDrawableDpad? = null + private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null + + // Stores the ID of the pointer that interacted with the 3DS touchscreen. + private var touchscreenPointerId = -1 + + init { + if (!preferences.getBoolean("OverlayInit", false)) { + defaultOverlay() + } + + // Reset 3ds touchscreen pointer ID + touchscreenPointerId = -1 + + // Load the controls. + refreshControls() + + // Set the on touch listener. + setOnTouchListener(this) + + // Force draw + setWillNotDraw(false) + + // Request focus for the overlay so it has priority on presses. + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + overlayButtons.forEach { it.draw(canvas) } + overlayDpads.forEach { it.draw(canvas) } + overlayJoysticks.forEach { it.draw(canvas) } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (isInEditMode) { + return onTouchWhileEditing(event) + } + var shouldUpdateView = false + for (button in overlayButtons) { + if (!button.updateStatus(event)) { + continue + } + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.id, button.status) + shouldUpdateView = true + } + for (dpad in overlayDpads) { + if (!dpad.updateStatus(event, EmulationMenuSettings.dpadSlide)) { + continue + } + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.upId, dpad.upStatus) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + dpad.downId, + dpad.downStatus + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + dpad.leftId, + dpad.leftStatus + ) + NativeLibrary.onGamePadEvent( + NativeLibrary.TouchScreenDevice, + dpad.rightId, + dpad.rightStatus + ) + shouldUpdateView = true + } + for (joystick in overlayJoysticks) { + if (!joystick.updateStatus(event)) { + continue + } + val axisID = joystick.joystickId + NativeLibrary.onGamePadMoveEvent( + NativeLibrary.TouchScreenDevice, + axisID, + joystick.xAxis, + joystick.yAxis + ) + shouldUpdateView = true + } + + if (shouldUpdateView) { + invalidate() + } + + if (!preferences.getBoolean("isTouchEnabled", true)) { + return true + } + + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionMove = motionEvent == MotionEvent.ACTION_MOVE + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchEvent(xPosition.toFloat(), yPosition.toFloat(), true) + } + if (isActionMove) { + for (i in 0 until event.pointerCount) { + val fingerId = event.getPointerId(i) + if (isTouchInputConsumed(fingerId)) { + continue + } + NativeLibrary.onTouchMoved(xPosition.toFloat(), yPosition.toFloat()) + } + } + if (isActionUp && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchEvent(0f, 0f, false) + } + return true + } + + private fun isTouchInputConsumed(trackId: Int): Boolean { + overlayButtons.forEach { + if (it.trackId == trackId) { + return true + } + } + overlayDpads.forEach { + if (it.trackId == trackId) { + return true + } + } + overlayJoysticks.forEach { + if (it.trackId == trackId) { + return true + } + } + return false + } + + fun onTouchWhileEditing(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + val orientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else "" + + // Maybe combine Button and Joystick as subclasses of the same parent? + // Or maybe create an interface like IMoveableHUDControl? + overlayButtons.forEach { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + it.bounds.contains(fingerPositionX, fingerPositionY) + ) { + buttonBeingConfigured = it + buttonBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { + buttonBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured == it) { + // Persist button position by saving new place. + saveControlPosition( + buttonBeingConfigured!!.id, + buttonBeingConfigured!!.bounds.left, + buttonBeingConfigured!!.bounds.top, orientation + ) + buttonBeingConfigured = null + } + } + } + overlayDpads.forEach { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + it.bounds.contains(fingerPositionX, fingerPositionY) + ) { + dpadBeingConfigured = it + dpadBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { + dpadBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured == it) { + // Persist button position by saving new place. + saveControlPosition( + dpadBeingConfigured!!.upId, + dpadBeingConfigured!!.bounds.left, dpadBeingConfigured!!.bounds.top, + orientation + ) + dpadBeingConfigured = null + } + } + } + overlayJoysticks.forEach { + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && + it.bounds.contains(fingerPositionX, fingerPositionY) + ) { + joystickBeingConfigured = it + joystickBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { + joystickBeingConfigured!!.onConfigureTouch(event) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { + saveControlPosition( + joystickBeingConfigured!!.joystickId, + joystickBeingConfigured!!.bounds.left, + joystickBeingConfigured!!.bounds.top, orientation + ) + joystickBeingConfigured = null + } + } + } + return true + } + + private fun addOverlayControls(orientation: String) { + if (preferences.getBoolean("buttonToggle0", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_a, + R.drawable.button_a_pressed, + NativeLibrary.ButtonType.BUTTON_A, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle1", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_b, + R.drawable.button_b_pressed, + NativeLibrary.ButtonType.BUTTON_B, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle2", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_x, + R.drawable.button_x_pressed, + NativeLibrary.ButtonType.BUTTON_X, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle3", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_y, + R.drawable.button_y_pressed, + NativeLibrary.ButtonType.BUTTON_Y, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle4", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_l, + R.drawable.button_l_pressed, + NativeLibrary.ButtonType.TRIGGER_L, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle5", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_r, + R.drawable.button_r_pressed, + NativeLibrary.ButtonType.TRIGGER_R, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle6", false)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_zl, + R.drawable.button_zl_pressed, + NativeLibrary.ButtonType.BUTTON_ZL, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle7", false)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_zr, + R.drawable.button_zr_pressed, + NativeLibrary.ButtonType.BUTTON_ZR, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle8", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_start, + R.drawable.button_start_pressed, + NativeLibrary.ButtonType.BUTTON_START, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle9", true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_select, + R.drawable.button_select_pressed, + NativeLibrary.ButtonType.BUTTON_SELECT, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle10", true)) { + overlayDpads.add( + initializeOverlayDpad( + context, + R.drawable.dpad, + R.drawable.dpad_pressed_one_direction, + R.drawable.dpad_pressed_two_directions, + NativeLibrary.ButtonType.DPAD_UP, + NativeLibrary.ButtonType.DPAD_DOWN, + NativeLibrary.ButtonType.DPAD_LEFT, + NativeLibrary.ButtonType.DPAD_RIGHT, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle11", true)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + R.drawable.stick_main_range, + R.drawable.stick_main, + R.drawable.stick_main_pressed, + NativeLibrary.ButtonType.STICK_LEFT, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle12", false)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + R.drawable.stick_c_range, + R.drawable.stick_c, + R.drawable.stick_c_pressed, + NativeLibrary.ButtonType.STICK_C, + orientation + ) + ) + } + if (preferences.getBoolean("buttonToggle13", false)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.button_home, + R.drawable.button_home_pressed, + NativeLibrary.ButtonType.BUTTON_HOME, + orientation + ) + ) + } + } + + fun refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear() + overlayDpads.clear() + overlayJoysticks.clear() + val orientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + "-Portrait" + } else { + "" + } + + // Add all the enabled overlay items back to the HashSet. + if (EmulationMenuSettings.showOverlay) { + addOverlayControls(orientation) + } + invalidate() + } + + private fun saveControlPosition(sharedPrefsId: Int, x: Int, y: Int, orientation: String) { + preferences.edit() + .putFloat("$sharedPrefsId$orientation-X", x.toFloat()) + .putFloat("$sharedPrefsId$orientation-Y", y.toFloat()) + .apply() + } + + fun setIsInEditMode(isInEditMode: Boolean) { + this.isInEditMode = isInEditMode + } + + private fun defaultOverlay() { + if (!preferences.getBoolean("OverlayInit", false)) { + // It's possible that a user has created their overlay before this was added + // Only change the overlay if the 'A' button is not in the upper corner. + val aButtonPosition = preferences.getFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + "-X", + 0f + ) + if (aButtonPosition == 0f) { + defaultOverlayLandscape() + } + + val aButtonPositionPortrait = preferences.getFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + "-Portrait" + "-X", + 0f + ) + if (aButtonPositionPortrait == 0f) { + defaultOverlayPortrait() + } + } + + preferences.edit() + .putBoolean("OverlayInit", true) + .apply() + } + + fun resetButtonPlacement() { + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + if (isLandscape) { + defaultOverlayLandscape() + } else { + defaultOverlayPortrait() + } + refreshControls() + } + + private fun defaultOverlayLandscape() { + // Get screen size + val display = (context as Activity).windowManager.defaultDisplay + val outMetrics = DisplayMetrics() + display.getMetrics(outMetrics) + var maxX = outMetrics.heightPixels.toFloat() + var maxY = outMetrics.widthPixels.toFloat() + // Height and width changes depending on orientation. Use the larger value for height. + if (maxY > maxX) { + val tmp = maxX + maxX = maxY + maxY = tmp + } + + // Each value is a percent from max X/Y stored as an int. Have to bring that value down + // to a decimal before multiplying by MAX X/Y. + preferences.edit() + .putFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_A_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_A_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_B.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_B_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_B.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_B_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_X.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_X_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_X.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_X_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_Y.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_Y_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_Y.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_Y_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZL.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_ZL_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZL.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_ZL_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZR.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_ZR_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZR.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_ZR_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.DPAD_UP.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_UP_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.DPAD_UP.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_UP_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_L.toString() + "-X", + resources.getInteger(R.integer.N3DS_TRIGGER_L_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_L.toString() + "-Y", + resources.getInteger(R.integer.N3DS_TRIGGER_L_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_R.toString() + "-X", + resources.getInteger(R.integer.N3DS_TRIGGER_R_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_R.toString() + "-Y", + resources.getInteger(R.integer.N3DS_TRIGGER_R_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_START.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_START_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_START.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_START_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_SELECT.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_SELECT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_SELECT.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_SELECT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_HOME.toString() + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_HOME_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_HOME.toString() + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_HOME_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.STICK_C.toString() + "-X", + resources.getInteger(R.integer.N3DS_STICK_C_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.STICK_C.toString() + "-Y", + resources.getInteger(R.integer.N3DS_STICK_C_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.STICK_LEFT.toString() + "-X", + resources.getInteger(R.integer.N3DS_STICK_MAIN_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.STICK_LEFT.toString() + "-Y", + resources.getInteger(R.integer.N3DS_STICK_MAIN_Y).toFloat() / 1000 * maxY + ) + .apply() + } + + private fun defaultOverlayPortrait() { + // Get screen size + val display = (context as Activity).windowManager.defaultDisplay + val outMetrics = DisplayMetrics() + display.getMetrics(outMetrics) + var maxX = outMetrics.heightPixels.toFloat() + var maxY = outMetrics.widthPixels.toFloat() + // Height and width changes depending on orientation. Use the larger value for height. + if (maxY < maxX) { + val tmp = maxX + maxX = maxY + maxY = tmp + } + val portrait = "-Portrait" + + // Each value is a percent from max X/Y stored as an int. Have to bring that value down + // to a decimal before multiplying by MAX X/Y. + preferences.edit() + .putFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_A.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_A_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_B.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_B.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_B_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_X.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_X.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_X_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_Y.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_Y.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_Y_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZL.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZL.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_ZL_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZR.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_ZR.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_ZR_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.DPAD_UP.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.DPAD_UP.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_UP_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_L.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_L.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_TRIGGER_L_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_R.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.TRIGGER_R.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_TRIGGER_R_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_START.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_START.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_START_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_SELECT.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_X) + .toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_SELECT.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_SELECT_PORTRAIT_Y) + .toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_HOME.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.BUTTON_HOME.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_BUTTON_HOME_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.STICK_C.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.STICK_C.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_STICK_C_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .putFloat( + NativeLibrary.ButtonType.STICK_LEFT.toString() + portrait + "-X", + resources.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_X).toFloat() / 1000 * maxX + ) + .putFloat( + NativeLibrary.ButtonType.STICK_LEFT.toString() + portrait + "-Y", + resources.getInteger(R.integer.N3DS_STICK_MAIN_PORTRAIT_Y).toFloat() / 1000 * maxY + ) + .apply() + } + + override fun isInEditMode(): Boolean { + return isInEditMode + } + + companion object { + private val preferences + get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context Context for getting the drawable/vector drawable + * @param drawableId The ID of the drawable to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { + try { + val bitmap = BitmapFactory.decodeResource(context.resources, drawableId) + return resizeBitmap(context, bitmap, scale) + } catch (_: NullPointerException) { + } + + val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable + + val bitmap = Bitmap.createBitmap( + (vectorDrawable.intrinsicWidth * scale).toInt(), + (vectorDrawable.intrinsicHeight * scale).toInt(), + Bitmap.Config.ARGB_8888 + ) + + val scaledBitmap = resizeBitmap(context, bitmap, scale) + val canvas = Canvas(scaledBitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return scaledBitmap + } + + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context The current [Context] + * @param bitmap The [Bitmap] to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun resizeBitmap(context: Context, bitmap: Bitmap, scale: Float): Bitmap { + // Determine the button size based on the smaller screen dimension. + // This makes sure the buttons are the same size in both portrait and landscape. + val dm = context.resources.displayMetrics + val minDimension = min(dm.widthPixels, dm.heightPixels) + return Bitmap.createScaledBitmap( + bitmap, + (minDimension * scale).toInt(), + (minDimension * scale).toInt(), + true + ) + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + * + * + * This works due to the way the X and Y coordinates are stored within + * the [SharedPreferences]. + * + * + * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored + * within a SharedPreferences instance so that those values can be retrieved here. + * + * + * This has a few benefits over the conventional way of storing the values + * (ie. within the Citra ini file). + * + * * No native calls + * * Keeps Android-only values inside the Android environment + * + * + * + * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current [Context]. + * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). + * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. + */ + private fun initializeOverlayButton( + context: Context, + defaultResId: Int, + pressedResId: Int, + buttonId: Int, + orientation: String + ): InputOverlayDrawableButton { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button ID and user preference + var scale: Float = when (buttonId) { + NativeLibrary.ButtonType.BUTTON_HOME, + NativeLibrary.ButtonType.BUTTON_START, + NativeLibrary.ButtonType.BUTTON_SELECT -> 0.08f + + NativeLibrary.ButtonType.TRIGGER_L, + NativeLibrary.ButtonType.TRIGGER_R, + NativeLibrary.ButtonType.BUTTON_ZL, + NativeLibrary.ButtonType.BUTTON_ZR -> 0.18f + + else -> 0.11f + } + scale *= (preferences.getInt("controlScale", 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableButton. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedStateBitmap = getBitmap(context, pressedResId, scale) + val overlayDrawable = + InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId) + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val xKey = "$buttonId$orientation-X" + val yKey = "$buttonId$orientation-Y" + val drawableX = preferences.getFloat(xKey, 0f).toInt() + val drawableY = preferences.getFloat(yKey, 0f).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableDpad] + * + * @param context The current [Context]. + * @param defaultResId The [Bitmap] resource ID of the default sate. + * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed sate in one direction. + * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed sate in two directions. + * @param buttonUp Identifier for the up button. + * @param buttonDown Identifier for the down button. + * @param buttonLeft Identifier for the left button. + * @param buttonRight Identifier for the right button. + * @return the initialized [InputOverlayDrawableDpad] + */ + private fun initializeOverlayDpad( + context: Context, + defaultResId: Int, + pressedOneDirectionResId: Int, + pressedTwoDirectionsResId: Int, + buttonUp: Int, + buttonDown: Int, + buttonLeft: Int, + buttonRight: Int, + orientation: String + ): InputOverlayDrawableDpad { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button ID and user preference + var scale = 0.22f + scale *= (preferences.getInt("controlScale", 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableDpad. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) + val pressedTwoDirectionsStateBitmap = getBitmap(context, pressedTwoDirectionsResId, scale) + val overlayDrawable = InputOverlayDrawableDpad( + res, + defaultStateBitmap, + pressedOneDirectionStateBitmap, + pressedTwoDirectionsStateBitmap, + buttonUp, + buttonDown, + buttonLeft, + buttonRight + ) + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = preferences.getFloat("$buttonUp$orientation-X", 0f).toInt() + val drawableY = preferences.getFloat("$buttonUp$orientation-Y", 0f).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds(drawableX, drawableY, drawableX + width, drawableY + height) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableJoystick] + * + * @param context The current [Context] + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @return the initialized [InputOverlayDrawableJoystick]. + */ + private fun initializeOverlayJoystick( + context: Context, + resOuter: Int, + defaultResInner: Int, + pressedResInner: Int, + joystick: Int, + orientation: String + ): InputOverlayDrawableJoystick { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on user preference + var scale = 0.275f + scale *= (preferences.getInt("controlScale", 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableJoystick. + val bitmapOuter = getBitmap(context, resOuter, scale) + val bitmapInnerDefault = getBitmap(context, defaultResInner, scale) + val bitmapInnerPressed = getBitmap(context, pressedResInner, scale) + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = preferences.getFloat("$joystick$orientation-X", 0f).toInt() + val drawableY = preferences.getFloat("$joystick$orientation-Y", 0f).toInt() + + // Decide inner scale based on joystick ID + var outerScale = 1f + if (joystick == NativeLibrary.ButtonType.STICK_C) { + outerScale = 2f + } + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + val outerSize = bitmapOuter.width + val outerRect = Rect( + drawableX, + drawableY, + drawableX + (outerSize / outerScale).toInt(), + drawableY + (outerSize / outerScale).toInt() + ) + val innerRect = + Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) + + // Send the drawableId to the joystick so it can be referenced when saving control position. + val overlayDrawable = InputOverlayDrawableJoystick( + res, + bitmapOuter, + bitmapInnerDefault, + bitmapInnerPressed, + outerRect, + innerRect, + joystick + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + return overlayDrawable + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java deleted file mode 100644 index ec49808af..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Copyright 2013 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.overlay; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.view.MotionEvent; - -import org.citra.citra_emu.NativeLibrary; - -/** - * Custom {@link BitmapDrawable} that is capable - * of storing it's own ID. - */ -public final class InputOverlayDrawableButton { - // The ID identifying what type of button this Drawable represents. - private int mButtonType; - private int mTrackId; - private int mPreviousTouchX, mPreviousTouchY; - private int mControlPositionX, mControlPositionY; - private int mWidth; - private int mHeight; - private BitmapDrawable mDefaultStateBitmap; - private BitmapDrawable mPressedStateBitmap; - private boolean mPressedState = false; - - /** - * Constructor - * - * @param res {@link Resources} instance. - * @param defaultStateBitmap {@link Bitmap} to use with the default state Drawable. - * @param pressedStateBitmap {@link Bitmap} to use with the pressed state Drawable. - * @param buttonType Identifier for this type of button. - */ - public InputOverlayDrawableButton(Resources res, Bitmap defaultStateBitmap, - Bitmap pressedStateBitmap, int buttonType) { - mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap); - mPressedStateBitmap = new BitmapDrawable(res, pressedStateBitmap); - mButtonType = buttonType; - mTrackId = -1; - - mWidth = mDefaultStateBitmap.getIntrinsicWidth(); - mHeight = mDefaultStateBitmap.getIntrinsicHeight(); - } - - /** - * Updates button status based on the motion event. - * - * @return true if value was changed - */ - public boolean updateStatus(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int xPosition = (int) event.getX(pointerIndex); - int yPosition = (int) event.getY(pointerIndex); - int pointerId = event.getPointerId(pointerIndex); - int motionEvent = event.getAction() & MotionEvent.ACTION_MASK; - boolean isActionDown = motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN; - boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP; - - if (isActionDown) { - if (!getBounds().contains(xPosition, yPosition)) { - return false; - } - mPressedState = true; - mTrackId = pointerId; - return true; - } - - if (isActionUp) { - if (mTrackId != pointerId) { - return false; - } - mPressedState = false; - mTrackId = -1; - return true; - } - - return false; - } - - public boolean onConfigureTouch(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int fingerPositionX = (int) event.getX(pointerIndex); - int fingerPositionY = (int) event.getY(pointerIndex); - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - case MotionEvent.ACTION_MOVE: - mControlPositionX += fingerPositionX - mPreviousTouchX; - mControlPositionY += fingerPositionY - mPreviousTouchY; - setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX, - getHeight() + mControlPositionY); - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - - } - return true; - } - - public void setPosition(int x, int y) { - mControlPositionX = x; - mControlPositionY = y; - } - - public void draw(Canvas canvas) { - getCurrentStateBitmapDrawable().draw(canvas); - } - - private BitmapDrawable getCurrentStateBitmapDrawable() { - return mPressedState ? mPressedStateBitmap : mDefaultStateBitmap; - } - - public void setBounds(int left, int top, int right, int bottom) { - mDefaultStateBitmap.setBounds(left, top, right, bottom); - mPressedStateBitmap.setBounds(left, top, right, bottom); - } - - public int getId() { - return mButtonType; - } - - public int getTrackId() { - return mTrackId; - } - - public void setTrackId(int trackId) { - mTrackId = trackId; - } - - public int getStatus() { - return mPressedState ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED; - } - - public Rect getBounds() { - return mDefaultStateBitmap.getBounds(); - } - - public int getWidth() { - return mWidth; - } - - public int getHeight() { - return mHeight; - } - - public void setPressedState(boolean isPressed) { - mPressedState = isPressed; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 000000000..5f83fa776 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,128 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.citra.citra_emu.NativeLibrary + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. + * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. + * @param id Identifier for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val id: Int +) { + var trackId: Int + private var previousTouchX = 0 + private var previousTouchY = 0 + private var controlPositionX = 0 + private var controlPositionY = 0 + val width: Int + val height: Int + private val defaultStateBitmap: BitmapDrawable + private val pressedStateBitmap: BitmapDrawable + private var pressedState = false + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap) + trackId = -1 + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + } + + /** + * Updates button status based on the motion event. + * + * @return true if value was changed + */ + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + trackId = pointerId + return true + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + trackId = -1 + return true + } + return false + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun draw(canvas: Canvas) = currentStateBitmapDrawable.draw(canvas) + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateBitmap else defaultStateBitmap + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedStateBitmap.setBounds(left, top, right, bottom) + } + + val status: Int + get() = if (pressedState) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED + val bounds: Rect + get() = defaultStateBitmap.bounds +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java deleted file mode 100644 index 2555b2c52..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Copyright 2016 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.overlay; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.view.MotionEvent; - -import org.citra.citra_emu.NativeLibrary; - -/** - * Custom {@link BitmapDrawable} that is capable - * of storing it's own ID. - */ -public final class InputOverlayDrawableDpad { - public static final float VIRT_AXIS_DEADZONE = 0.5f; - // The ID identifying what type of button this Drawable represents. - private int mUpButtonId; - private int mDownButtonId; - private int mLeftButtonId; - private int mRightButtonId; - private int mTrackId; - private int mPreviousTouchX, mPreviousTouchY; - private int mControlPositionX, mControlPositionY; - private int mWidth; - private int mHeight; - private BitmapDrawable mDefaultStateBitmap; - private BitmapDrawable mPressedOneDirectionStateBitmap; - private BitmapDrawable mPressedTwoDirectionsStateBitmap; - private boolean mUpButtonState; - private boolean mDownButtonState; - private boolean mLeftButtonState; - private boolean mRightButtonState; - - /** - * Constructor - * - * @param res {@link Resources} instance. - * @param defaultStateBitmap {@link Bitmap} of the default state. - * @param pressedOneDirectionStateBitmap {@link Bitmap} of the pressed state in one direction. - * @param pressedTwoDirectionsStateBitmap {@link Bitmap} of the pressed state in two direction. - * @param buttonUp Identifier for the up button. - * @param buttonDown Identifier for the down button. - * @param buttonLeft Identifier for the left button. - * @param buttonRight Identifier for the right button. - */ - public InputOverlayDrawableDpad(Resources res, - Bitmap defaultStateBitmap, - Bitmap pressedOneDirectionStateBitmap, - Bitmap pressedTwoDirectionsStateBitmap, - int buttonUp, int buttonDown, - int buttonLeft, int buttonRight) { - mDefaultStateBitmap = new BitmapDrawable(res, defaultStateBitmap); - mPressedOneDirectionStateBitmap = new BitmapDrawable(res, pressedOneDirectionStateBitmap); - mPressedTwoDirectionsStateBitmap = new BitmapDrawable(res, pressedTwoDirectionsStateBitmap); - - mWidth = mDefaultStateBitmap.getIntrinsicWidth(); - mHeight = mDefaultStateBitmap.getIntrinsicHeight(); - - mUpButtonId = buttonUp; - mDownButtonId = buttonDown; - mLeftButtonId = buttonLeft; - mRightButtonId = buttonRight; - - mTrackId = -1; - } - - public boolean updateStatus(MotionEvent event, boolean dpadSlide) { - int pointerIndex = event.getActionIndex(); - int xPosition = (int) event.getX(pointerIndex); - int yPosition = (int) event.getY(pointerIndex); - int pointerId = event.getPointerId(pointerIndex); - int motionEvent = event.getAction() & MotionEvent.ACTION_MASK; - boolean isActionDown = motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN; - boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP; - - if (isActionDown) { - if (!getBounds().contains(xPosition, yPosition)) { - return false; - } - mTrackId = pointerId; - } - - if (isActionUp) { - if (mTrackId != pointerId) { - return false; - } - mTrackId = -1; - mUpButtonState = false; - mDownButtonState = false; - mLeftButtonState = false; - mRightButtonState = false; - return true; - } - - if (mTrackId == -1) { - return false; - } - - if (!dpadSlide && !isActionDown) { - return false; - } - - for (int i = 0; i < event.getPointerCount(); i++) { - if (mTrackId != event.getPointerId(i)) { - continue; - } - float touchX = event.getX(i); - float touchY = event.getY(i); - float maxY = getBounds().bottom; - float maxX = getBounds().right; - touchX -= getBounds().centerX(); - maxX -= getBounds().centerX(); - touchY -= getBounds().centerY(); - maxY -= getBounds().centerY(); - final float AxisX = touchX / maxX; - final float AxisY = touchY / maxY; - final boolean upState = mUpButtonState; - final boolean downState = mDownButtonState; - final boolean leftState = mLeftButtonState; - final boolean rightState = mRightButtonState; - - mUpButtonState = AxisY < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE; - mDownButtonState = AxisY > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE; - mLeftButtonState = AxisX < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE; - mRightButtonState = AxisX > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE; - return upState != mUpButtonState || downState != mDownButtonState || leftState != mLeftButtonState || rightState != mRightButtonState; - } - - return false; - } - - public void draw(Canvas canvas) { - int px = mControlPositionX + (getWidth() / 2); - int py = mControlPositionY + (getHeight() / 2); - - // Pressed up - if (mUpButtonState && !mLeftButtonState && !mRightButtonState) { - mPressedOneDirectionStateBitmap.draw(canvas); - return; - } - - // Pressed down - if (mDownButtonState && !mLeftButtonState && !mRightButtonState) { - canvas.save(); - canvas.rotate(180, px, py); - mPressedOneDirectionStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Pressed left - if (mLeftButtonState && !mUpButtonState && !mDownButtonState) { - canvas.save(); - canvas.rotate(270, px, py); - mPressedOneDirectionStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Pressed right - if (mRightButtonState && !mUpButtonState && !mDownButtonState) { - canvas.save(); - canvas.rotate(90, px, py); - mPressedOneDirectionStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Pressed up left - if (mUpButtonState && mLeftButtonState && !mRightButtonState) { - mPressedTwoDirectionsStateBitmap.draw(canvas); - return; - } - - // Pressed up right - if (mUpButtonState && !mLeftButtonState && mRightButtonState) { - canvas.save(); - canvas.rotate(90, px, py); - mPressedTwoDirectionsStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Pressed down left - if (mDownButtonState && mLeftButtonState && !mRightButtonState) { - canvas.save(); - canvas.rotate(270, px, py); - mPressedTwoDirectionsStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Pressed down right - if (mDownButtonState && !mLeftButtonState && mRightButtonState) { - canvas.save(); - canvas.rotate(180, px, py); - mPressedTwoDirectionsStateBitmap.draw(canvas); - canvas.restore(); - return; - } - - // Not pressed - mDefaultStateBitmap.draw(canvas); - } - - public int getUpId() { - return mUpButtonId; - } - - public int getDownId() { - return mDownButtonId; - } - - public int getLeftId() { - return mLeftButtonId; - } - - public int getRightId() { - return mRightButtonId; - } - - public int getTrackId() { - return mTrackId; - } - - public void setTrackId(int trackId) { - mTrackId = trackId; - } - - public int getUpStatus() { - return mUpButtonState ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED; - } - - public int getDownStatus() { - return mDownButtonState ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED; - } - - public int getLeftStatus() { - return mLeftButtonState ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED; - } - - public int getRightStatus() { - return mRightButtonState ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED; - } - - public boolean onConfigureTouch(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int fingerPositionX = (int) event.getX(pointerIndex); - int fingerPositionY = (int) event.getY(pointerIndex); - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - case MotionEvent.ACTION_MOVE: - mControlPositionX += fingerPositionX - mPreviousTouchX; - mControlPositionY += fingerPositionY - mPreviousTouchY; - setBounds(mControlPositionX, mControlPositionY, getWidth() + mControlPositionX, - getHeight() + mControlPositionY); - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - - } - return true; - } - - public void setPosition(int x, int y) { - mControlPositionX = x; - mControlPositionY = y; - } - - public void setBounds(int left, int top, int right, int bottom) { - mDefaultStateBitmap.setBounds(left, top, right, bottom); - mPressedOneDirectionStateBitmap.setBounds(left, top, right, bottom); - mPressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom); - } - - public Rect getBounds() { - return mDefaultStateBitmap.getBounds(); - } - - public int getWidth() { - return mWidth; - } - - public int getHeight() { - return mHeight; - } - -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 000000000..f7a5a3fe5 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,262 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.citra.citra_emu.NativeLibrary + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] of the default state. + * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. + * @param upId Identifier for the up button. + * @param downId Identifier for the down button. + * @param leftId Identifier for the left button. + * @param rightId Identifier for the right button. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap, + val upId: Int, + val downId: Int, + val leftId: Int, + val rightId: Int +) { + var trackId: Int + private var previousTouchX = 0 + private var previousTouchY = 0 + private var controlPositionX = 0 + private var controlPositionY = 0 + val width: Int + val height: Int + private val defaultStateBitmap: BitmapDrawable + private val pressedOneDirectionStateBitmap: BitmapDrawable + private val pressedTwoDirectionsStateBitmap: BitmapDrawable + private var upButtonState = false + private var downButtonState = false + private var leftButtonState = false + private var rightButtonState = false + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap) + this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + trackId = -1 + } + + fun updateStatus(event: MotionEvent, dpadSlide: Boolean): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + trackId = pointerId + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + trackId = -1 + upButtonState = false + downButtonState = false + leftButtonState = false + rightButtonState = false + return true + } + if (trackId == -1) { + return false + } + if (!dpadSlide && !isActionDown) { + return false + } + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = bounds.bottom.toFloat() + var maxX = bounds.right.toFloat() + touchX -= bounds.centerX().toFloat() + maxX -= bounds.centerX().toFloat() + touchY -= bounds.centerY().toFloat() + maxY -= bounds.centerY().toFloat() + val xAxis = touchX / maxX + val yAxis = touchY / maxY + val upState = upButtonState + val downState = downButtonState + val leftState = leftButtonState + val rightState = rightButtonState + upButtonState = yAxis < -VIRT_AXIS_DEADZONE + downButtonState = yAxis > VIRT_AXIS_DEADZONE + leftButtonState = xAxis < -VIRT_AXIS_DEADZONE + rightButtonState = xAxis > VIRT_AXIS_DEADZONE + return upState != upButtonState || downState != downButtonState || leftState != leftButtonState || rightState != rightButtonState + } + return false + } + + fun draw(canvas: Canvas) { + val px = controlPositionX + width / 2 + val py = controlPositionY + height / 2 + + // Pressed up + if (upButtonState && !leftButtonState && !rightButtonState) { + pressedOneDirectionStateBitmap.draw(canvas) + return + } + + // Pressed down + if (downButtonState && !leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed left + if (leftButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed right + if (rightButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed up left + if (upButtonState && leftButtonState && !rightButtonState) { + pressedTwoDirectionsStateBitmap.draw(canvas) + return + } + + // Pressed up right + if (upButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down left + if (downButtonState && leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down right + if (downButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Not pressed + defaultStateBitmap.draw(canvas) + } + + val upStatus: Int + get() = if (upButtonState) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + val downStatus: Int + get() = if (downButtonState) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + val leftStatus: Int + get() = if (leftButtonState) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + val rightStatus: Int + get() = if (rightButtonState) { + NativeLibrary.ButtonState.PRESSED + } else { + NativeLibrary.ButtonState.RELEASED + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, controlPositionY, width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom) + pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom) + } + + val bounds: Rect + get() = defaultStateBitmap.bounds + + companion object { + private const val VIRT_AXIS_DEADZONE = 0.5f + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java deleted file mode 100644 index f25771afc..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java +++ /dev/null @@ -1,267 +0,0 @@ -/** - * Copyright 2013 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.overlay; - -import android.content.res.Resources; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.BitmapDrawable; -import android.view.MotionEvent; - -import org.citra.citra_emu.NativeLibrary.ButtonType; -import org.citra.citra_emu.utils.EmulationMenuSettings; - -/** - * Custom {@link BitmapDrawable} that is capable - * of storing it's own ID. - */ -public final class InputOverlayDrawableJoystick { - // The ID value what type of joystick this Drawable represents. - private int mJoystickId; - // The ID value what motion event is tracking - private int mTrackId = -1; - private float mXAxis; - private float mYAxis; - private int mControlPositionX, mControlPositionY; - private int mPreviousTouchX, mPreviousTouchY; - private int mWidth; - private int mHeight; - private Rect mVirtBounds; - private Rect mOrigBounds; - private BitmapDrawable mOuterBitmap; - private BitmapDrawable mDefaultStateInnerBitmap; - private BitmapDrawable mPressedStateInnerBitmap; - private BitmapDrawable mBoundsBoxBitmap; - private boolean mPressedState = false; - - /** - * Constructor - * - * @param res {@link Resources} instance. - * @param bitmapOuter {@link Bitmap} which represents the outer non-movable part of the joystick. - * @param bitmapInnerDefault {@link Bitmap} which represents the default inner movable part of the joystick. - * @param bitmapInnerPressed {@link Bitmap} which represents the pressed inner movable part of the joystick. - * @param rectOuter {@link Rect} which represents the outer joystick bounds. - * @param rectInner {@link Rect} which represents the inner joystick bounds. - * @param joystick Identifier for which joystick this is. - */ - public InputOverlayDrawableJoystick(Resources res, Bitmap bitmapOuter, - Bitmap bitmapInnerDefault, Bitmap bitmapInnerPressed, - Rect rectOuter, Rect rectInner, int joystick) { - mJoystickId = joystick; - - mOuterBitmap = new BitmapDrawable(res, bitmapOuter); - mDefaultStateInnerBitmap = new BitmapDrawable(res, bitmapInnerDefault); - mPressedStateInnerBitmap = new BitmapDrawable(res, bitmapInnerPressed); - mBoundsBoxBitmap = new BitmapDrawable(res, bitmapOuter); - mWidth = bitmapOuter.getWidth(); - mHeight = bitmapOuter.getHeight(); - - setBounds(rectOuter); - mDefaultStateInnerBitmap.setBounds(rectInner); - mPressedStateInnerBitmap.setBounds(rectInner); - mVirtBounds = getBounds(); - mOrigBounds = mOuterBitmap.copyBounds(); - mBoundsBoxBitmap.setAlpha(0); - mBoundsBoxBitmap.setBounds(getVirtBounds()); - SetInnerBounds(); - } - - public void draw(Canvas canvas) { - mOuterBitmap.draw(canvas); - getCurrentStateBitmapDrawable().draw(canvas); - mBoundsBoxBitmap.draw(canvas); - } - - public boolean updateStatus(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int xPosition = (int) event.getX(pointerIndex); - int yPosition = (int) event.getY(pointerIndex); - int pointerId = event.getPointerId(pointerIndex); - int motionEvent = event.getAction() & MotionEvent.ACTION_MASK; - boolean isActionDown = motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN; - boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP; - - if (isActionDown) { - if (!getBounds().contains(xPosition, yPosition)) { - return false; - } - mPressedState = true; - mOuterBitmap.setAlpha(0); - mBoundsBoxBitmap.setAlpha(255); - if (EmulationMenuSettings.INSTANCE.getJoystickRelCenter()) { - getVirtBounds().offset(xPosition - getVirtBounds().centerX(), - yPosition - getVirtBounds().centerY()); - } - mBoundsBoxBitmap.setBounds(getVirtBounds()); - mTrackId = pointerId; - } - - if (isActionUp) { - if (mTrackId != pointerId) { - return false; - } - mPressedState = false; - mXAxis = 0.0f; - mYAxis = 0.0f; - mOuterBitmap.setAlpha(255); - mBoundsBoxBitmap.setAlpha(0); - setVirtBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right, - mOrigBounds.bottom)); - setBounds(new Rect(mOrigBounds.left, mOrigBounds.top, mOrigBounds.right, - mOrigBounds.bottom)); - SetInnerBounds(); - mTrackId = -1; - return true; - } - - if (mTrackId == -1) - return false; - - for (int i = 0; i < event.getPointerCount(); i++) { - if (mTrackId != event.getPointerId(i)) { - continue; - } - float touchX = event.getX(i); - float touchY = event.getY(i); - float maxY = getVirtBounds().bottom; - float maxX = getVirtBounds().right; - touchX -= getVirtBounds().centerX(); - maxX -= getVirtBounds().centerX(); - touchY -= getVirtBounds().centerY(); - maxY -= getVirtBounds().centerY(); - final float AxisX = touchX / maxX; - final float AxisY = touchY / maxY; - final float oldXAxis = mXAxis; - final float oldYAxis = mYAxis; - - // Clamp the circle pad input to a circle - final float angle = (float) Math.atan2(AxisY, AxisX); - float radius = (float) Math.sqrt(AxisX * AxisX + AxisY * AxisY); - if (radius > 1.0f) { - radius = 1.0f; - } - mXAxis = ((float) Math.cos(angle) * radius); - mYAxis = ((float) Math.sin(angle) * radius); - SetInnerBounds(); - return oldXAxis != mXAxis && oldYAxis != mYAxis; - } - - return false; - } - - public boolean onConfigureTouch(MotionEvent event) { - int pointerIndex = event.getActionIndex(); - int fingerPositionX = (int) event.getX(pointerIndex); - int fingerPositionY = (int) event.getY(pointerIndex); - - int scale = 1; - if (mJoystickId == ButtonType.STICK_C) { - // C-stick is scaled down to be half the size of the circle pad - scale = 2; - } - - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - case MotionEvent.ACTION_MOVE: - int deltaX = fingerPositionX - mPreviousTouchX; - int deltaY = fingerPositionY - mPreviousTouchY; - mControlPositionX += deltaX; - mControlPositionY += deltaY; - setBounds(new Rect(mControlPositionX, mControlPositionY, - mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, - mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)); - setVirtBounds(new Rect(mControlPositionX, mControlPositionY, - mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, - mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY)); - SetInnerBounds(); - setOrigBounds(new Rect(new Rect(mControlPositionX, mControlPositionY, - mOuterBitmap.getIntrinsicWidth() / scale + mControlPositionX, - mOuterBitmap.getIntrinsicHeight() / scale + mControlPositionY))); - mPreviousTouchX = fingerPositionX; - mPreviousTouchY = fingerPositionY; - break; - } - return true; - } - - public int getJoystickId() { - return mJoystickId; - } - - public float getXAxis() { - return mXAxis; - } - - public float getYAxis() { - return mYAxis; - } - - public int getTrackId() { - return mTrackId; - } - - private void SetInnerBounds() { - int X = getVirtBounds().centerX() + (int) ((mXAxis) * (getVirtBounds().width() / 2)); - int Y = getVirtBounds().centerY() + (int) ((mYAxis) * (getVirtBounds().height() / 2)); - - if (X > getVirtBounds().centerX() + (getVirtBounds().width() / 2)) - X = getVirtBounds().centerX() + (getVirtBounds().width() / 2); - if (X < getVirtBounds().centerX() - (getVirtBounds().width() / 2)) - X = getVirtBounds().centerX() - (getVirtBounds().width() / 2); - if (Y > getVirtBounds().centerY() + (getVirtBounds().height() / 2)) - Y = getVirtBounds().centerY() + (getVirtBounds().height() / 2); - if (Y < getVirtBounds().centerY() - (getVirtBounds().height() / 2)) - Y = getVirtBounds().centerY() - (getVirtBounds().height() / 2); - - int width = mPressedStateInnerBitmap.getBounds().width() / 2; - int height = mPressedStateInnerBitmap.getBounds().height() / 2; - mDefaultStateInnerBitmap.setBounds(X - width, Y - height, X + width, Y + height); - mPressedStateInnerBitmap.setBounds(mDefaultStateInnerBitmap.getBounds()); - } - - public void setPosition(int x, int y) { - mControlPositionX = x; - mControlPositionY = y; - } - - private BitmapDrawable getCurrentStateBitmapDrawable() { - return mPressedState ? mPressedStateInnerBitmap : mDefaultStateInnerBitmap; - } - - public Rect getBounds() { - return mOuterBitmap.getBounds(); - } - - public void setBounds(Rect bounds) { - mOuterBitmap.setBounds(bounds); - } - - private void setOrigBounds(Rect bounds) { - mOrigBounds = bounds; - } - - private Rect getVirtBounds() { - return mVirtBounds; - } - - private void setVirtBounds(Rect bounds) { - mVirtBounds = bounds; - } - - public int getWidth() { - return mWidth; - } - - public int getHeight() { - return mHeight; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.kt new file mode 100644 index 000000000..f521077a4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.kt @@ -0,0 +1,238 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.utils.EmulationMenuSettings +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. + * @param rectOuter [Rect] which represents the outer joystick bounds. + * @param rectInner [Rect] which represents the inner joystick bounds. + * @param joystickId Identifier for which joystick this is. + */ +class InputOverlayDrawableJoystick( + res: Resources, + bitmapOuter: Bitmap, + bitmapInnerDefault: Bitmap, + bitmapInnerPressed: Bitmap, + rectOuter: Rect, + rectInner: Rect, + val joystickId: Int +) { + var trackId = -1 + var xAxis = 0f + var yAxis = 0f + private var controlPositionX = 0 + private var controlPositionY = 0 + private var previousTouchX = 0 + private var previousTouchY = 0 + val width: Int + val height: Int + private var virtBounds: Rect + private var origBounds: Rect + private val outerBitmap: BitmapDrawable + private val defaultStateInnerBitmap: BitmapDrawable + private val pressedStateInnerBitmap: BitmapDrawable + private val boundsBoxBitmap: BitmapDrawable + private var pressedState = false + + var bounds: Rect + get() = outerBitmap.bounds + set(bounds) { + outerBitmap.bounds = bounds + } + + init { + outerBitmap = BitmapDrawable(res, bitmapOuter) + defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) + pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) + boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) + width = bitmapOuter.width + height = bitmapOuter.height + bounds = rectOuter + defaultStateInnerBitmap.bounds = rectInner + pressedStateInnerBitmap.bounds = rectInner + virtBounds = bounds + origBounds = outerBitmap.copyBounds() + boundsBoxBitmap.alpha = 0 + boundsBoxBitmap.bounds = virtBounds + setInnerBounds() + } + + fun draw(canvas: Canvas?) { + outerBitmap.draw(canvas!!) + currentStateBitmapDrawable.draw(canvas) + boundsBoxBitmap.draw(canvas) + } + + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = 255 + if (EmulationMenuSettings.joystickRelCenter) { + virtBounds.offset( + xPosition - virtBounds.centerX(), + yPosition - virtBounds.centerY() + ) + } + boundsBoxBitmap.bounds = virtBounds + trackId = pointerId + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + xAxis = 0.0f + yAxis = 0.0f + outerBitmap.alpha = 255 + boundsBoxBitmap.alpha = 0 + virtBounds = Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) + bounds = Rect(origBounds.left, origBounds.top, origBounds.right, origBounds.bottom) + setInnerBounds() + trackId = -1 + return true + } + if (trackId == -1) return false + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = virtBounds.bottom.toFloat() + var maxX = virtBounds.right.toFloat() + touchX -= virtBounds.centerX().toFloat() + maxX -= virtBounds.centerX().toFloat() + touchY -= virtBounds.centerY().toFloat() + maxY -= virtBounds.centerY().toFloat() + val xAxis = touchX / maxX + val yAxis = touchY / maxY + val oldXAxis = this.xAxis + val oldYAxis = this.yAxis + + // Clamp the circle pad input to a circle + val angle = atan2(yAxis.toDouble(), xAxis.toDouble()).toFloat() + var radius = sqrt((xAxis * xAxis + yAxis * yAxis).toDouble()).toFloat() + if (radius > 1.0f) { + radius = 1.0f + } + this.xAxis = cos(angle.toDouble()).toFloat() * radius + this.yAxis = sin(angle.toDouble()).toFloat() * radius + setInnerBounds() + return oldXAxis != this.xAxis && oldYAxis != this.yAxis + } + return false + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + var scale = 1 + if (joystickId == NativeLibrary.ButtonType.STICK_C) { + // C-stick is scaled down to be half the size of the circle pad + scale = 2 + } + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + val deltaX = fingerPositionX - previousTouchX + val deltaY = fingerPositionY - previousTouchY + controlPositionX += deltaX + controlPositionY += deltaY + bounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth / scale + controlPositionX, + outerBitmap.intrinsicHeight / scale + controlPositionY + ) + virtBounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth / scale + controlPositionX, + outerBitmap.intrinsicHeight / scale + controlPositionY + ) + setInnerBounds() + setOrigBounds( + Rect( + Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth / scale + controlPositionX, + outerBitmap.intrinsicHeight / scale + controlPositionY + ) + ) + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + private fun setInnerBounds() { + var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() + var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() + if (x > virtBounds.centerX() + virtBounds.width() / 2) x = + virtBounds.centerX() + virtBounds.width() / 2 + if (x < virtBounds.centerX() - virtBounds.width() / 2) x = + virtBounds.centerX() - virtBounds.width() / 2 + if (y > virtBounds.centerY() + virtBounds.height() / 2) y = + virtBounds.centerY() + virtBounds.height() / 2 + if (y < virtBounds.centerY() - virtBounds.height() / 2) y = + virtBounds.centerY() - virtBounds.height() / 2 + val width = pressedStateInnerBitmap.bounds.width() / 2 + val height = pressedStateInnerBitmap.bounds.height() / 2 + defaultStateInnerBitmap.setBounds(x - width, y - height, x + width, y + height) + pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap + + private fun setOrigBounds(bounds: Rect) { + origBounds = bounds + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java deleted file mode 100644 index 96ccc08bb..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java +++ /dev/null @@ -1,130 +0,0 @@ -package org.citra.citra_emu.ui; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.util.AttributeSet; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -/** - * Implementation from: - * https://gist.github.com/lapastillaroja/858caf1a82791b6c1a36 - */ -public class DividerItemDecoration extends RecyclerView.ItemDecoration { - - private Drawable mDivider; - private boolean mShowFirstDivider = false; - private boolean mShowLastDivider = false; - - public DividerItemDecoration(Context context, AttributeSet attrs) { - final TypedArray a = context - .obtainStyledAttributes(attrs, new int[]{android.R.attr.listDivider}); - mDivider = a.getDrawable(0); - a.recycle(); - } - - public DividerItemDecoration(Context context, AttributeSet attrs, boolean showFirstDivider, - boolean showLastDivider) { - this(context, attrs); - mShowFirstDivider = showFirstDivider; - mShowLastDivider = showLastDivider; - } - - public DividerItemDecoration(Drawable divider) { - mDivider = divider; - } - - public DividerItemDecoration(Drawable divider, boolean showFirstDivider, - boolean showLastDivider) { - this(divider); - mShowFirstDivider = showFirstDivider; - mShowLastDivider = showLastDivider; - } - - @Override - public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, - @NonNull RecyclerView.State state) { - super.getItemOffsets(outRect, view, parent, state); - if (mDivider == null) { - return; - } - if (parent.getChildAdapterPosition(view) < 1) { - return; - } - - if (getOrientation(parent) == LinearLayoutManager.VERTICAL) { - outRect.top = mDivider.getIntrinsicHeight(); - } else { - outRect.left = mDivider.getIntrinsicWidth(); - } - } - - @Override - public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state) { - if (mDivider == null) { - super.onDrawOver(c, parent, state); - return; - } - - // Initialization needed to avoid compiler warning - int left = 0, right = 0, top = 0, bottom = 0, size; - int orientation = getOrientation(parent); - int childCount = parent.getChildCount(); - - if (orientation == LinearLayoutManager.VERTICAL) { - size = mDivider.getIntrinsicHeight(); - left = parent.getPaddingLeft(); - right = parent.getWidth() - parent.getPaddingRight(); - } else { //horizontal - size = mDivider.getIntrinsicWidth(); - top = parent.getPaddingTop(); - bottom = parent.getHeight() - parent.getPaddingBottom(); - } - - for (int i = mShowFirstDivider ? 0 : 1; i < childCount; i++) { - View child = parent.getChildAt(i); - RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); - - if (orientation == LinearLayoutManager.VERTICAL) { - top = child.getTop() - params.topMargin; - bottom = top + size; - } else { //horizontal - left = child.getLeft() - params.leftMargin; - right = left + size; - } - mDivider.setBounds(left, top, right, bottom); - mDivider.draw(c); - } - - // show last divider - if (mShowLastDivider && childCount > 0) { - View child = parent.getChildAt(childCount - 1); - RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams(); - if (orientation == LinearLayoutManager.VERTICAL) { - top = child.getBottom() + params.bottomMargin; - bottom = top + size; - } else { // horizontal - left = child.getRight() + params.rightMargin; - right = left + size; - } - mDivider.setBounds(left, top, right, bottom); - mDivider.draw(c); - } - } - - private int getOrientation(RecyclerView parent) { - if (parent.getLayoutManager() instanceof LinearLayoutManager) { - LinearLayoutManager layoutManager = (LinearLayoutManager) parent.getLayoutManager(); - return layoutManager.getOrientation(); - } else { - throw new IllegalStateException( - "DividerItemDecoration can only be used with a LinearLayoutManager."); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java deleted file mode 100644 index 84ddf1439..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.citra.citra_emu.ui; - -import android.content.Context; -import android.view.View; -import android.view.inputmethod.InputMethodManager; - -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) { - closeKeyboard(); - setEnabled(false); - } - - private void closeKeyboard() { - InputMethodManager manager = (InputMethodManager) mSlidingPaneLayout.getContext() - .getSystemService(Context.INPUT_METHOD_SERVICE); - manager.hideSoftInputFromWindow(mSlidingPaneLayout.getRootView().getWindowToken(), 0); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.kt b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.kt new file mode 100644 index 000000000..5174e679a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.kt @@ -0,0 +1,40 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.ui + +import android.content.Context +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.activity.OnBackPressedCallback +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import androidx.slidingpanelayout.widget.SlidingPaneLayout.PanelSlideListener + +class TwoPaneOnBackPressedCallback(private val slidingPaneLayout: SlidingPaneLayout) : + OnBackPressedCallback(slidingPaneLayout.isSlideable && slidingPaneLayout.isOpen), + PanelSlideListener { + init { + slidingPaneLayout.addPanelSlideListener(this) + } + + override fun handleOnBackPressed() { + slidingPaneLayout.close() + } + + override fun onPanelSlide(panel: View, slideOffset: Float) {} + override fun onPanelOpened(panel: View) { + isEnabled = true + } + + override fun onPanelClosed(panel: View) { + closeKeyboard() + isEnabled = false + } + + private fun closeKeyboard() { + val manager = slidingPaneLayout.context + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + manager.hideSoftInputFromWindow(slidingPaneLayout.rootView.windowToken, 0) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java deleted file mode 100644 index 886846ec5..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.citra.citra_emu.utils; - -public interface Action1 { - void call(T t); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java deleted file mode 100644 index dfbab1780..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.citra.citra_emu.utils; - -import java.util.HashMap; -import java.util.Map; - -public class BiMap { - private Map forward = new HashMap(); - private Map backward = new HashMap(); - - public synchronized void add(K key, V value) { - forward.put(key, value); - backward.put(value, key); - } - - public synchronized V getForward(K key) { - return forward.get(key); - } - - public synchronized K getBackward(V key) { - return backward.get(key); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.kt new file mode 100644 index 000000000..e444233ee --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.kt @@ -0,0 +1,22 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +class BiMap { + private val forward: MutableMap = HashMap() + private val backward: MutableMap = HashMap() + + @Synchronized + fun add(key: K, value: V) { + forward[key] = value + backward[value] = key + } + + @Synchronized + fun getForward(key: K): V? = forward[key] + + @Synchronized + fun getBackward(key: V): K? = backward[key] +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java deleted file mode 100644 index 22f58ea4f..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java +++ /dev/null @@ -1,153 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.app.Notification; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; -import androidx.work.ForegroundInfo; -import androidx.work.Worker; -import androidx.work.WorkerParameters; - -import org.citra.citra_emu.NativeLibrary.InstallStatus; -import org.citra.citra_emu.R; - -public class CiaInstallWorker extends Worker { - private final Context mContext = getApplicationContext(); - - private final NotificationManager mNotificationManager = - mContext.getSystemService(NotificationManager.class); - - static final String GROUP_KEY_CIA_INSTALL_STATUS = "org.citra.citra_emu.CIA_INSTALL_STATUS"; - - private final NotificationCompat.Builder mInstallProgressBuilder = new NotificationCompat.Builder( - mContext, mContext.getString(R.string.cia_install_notification_channel_id)) - .setContentTitle(mContext.getString(R.string.install_cia_title)) - .setContentIntent(PendingIntent.getBroadcast(mContext, 0, - new Intent("CitraDoNothing"), PendingIntent.FLAG_IMMUTABLE)) - .setSmallIcon(R.drawable.ic_stat_notification_logo); - - private final NotificationCompat.Builder mInstallStatusBuilder = new NotificationCompat.Builder( - mContext, mContext.getString(R.string.cia_install_notification_channel_id)) - .setContentTitle(mContext.getString(R.string.install_cia_title)) - .setSmallIcon(R.drawable.ic_stat_notification_logo) - .setGroup(GROUP_KEY_CIA_INSTALL_STATUS); - - private final Notification mSummaryNotification = - new NotificationCompat.Builder(mContext, mContext.getString(R.string.cia_install_notification_channel_id)) - .setContentTitle(mContext.getString(R.string.install_cia_title)) - .setSmallIcon(R.drawable.ic_stat_notification_logo) - .setGroup(GROUP_KEY_CIA_INSTALL_STATUS) - .setGroupSummary(true) - .build(); - - private static long mLastNotifiedTime = 0; - - private static final int SUMMARY_NOTIFICATION_ID = 0xC1A0000; - private static final int PROGRESS_NOTIFICATION_ID = SUMMARY_NOTIFICATION_ID + 1; - private static int mStatusNotificationId = SUMMARY_NOTIFICATION_ID + 2; - - public CiaInstallWorker( - @NonNull Context context, - @NonNull WorkerParameters params) { - super(context, params); - } - - private void notifyInstallStatus(String filename, InstallStatus status) { - switch(status){ - case Success: - mInstallStatusBuilder.setContentTitle( - mContext.getString(R.string.cia_install_notification_success_title)); - mInstallStatusBuilder.setContentText( - mContext.getString(R.string.cia_install_success, filename)); - break; - case ErrorAborted: - mInstallStatusBuilder.setContentTitle( - mContext.getString(R.string.cia_install_notification_error_title)); - mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mContext.getString( - R.string.cia_install_error_aborted, filename))); - break; - case ErrorInvalid: - mInstallStatusBuilder.setContentTitle( - mContext.getString(R.string.cia_install_notification_error_title)); - mInstallStatusBuilder.setContentText( - mContext.getString(R.string.cia_install_error_invalid, filename)); - break; - case ErrorEncrypted: - mInstallStatusBuilder.setContentTitle( - mContext.getString(R.string.cia_install_notification_error_title)); - mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mContext.getString( - R.string.cia_install_error_encrypted, filename))); - break; - case ErrorFailedToOpenFile: - // TODO: - case ErrorFileNotFound: - // shouldn't happen - default: - mInstallStatusBuilder.setContentTitle( - mContext.getString(R.string.cia_install_notification_error_title)); - mInstallStatusBuilder.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mContext.getString(R.string.cia_install_error_unknown, filename))); - break; - } - // Even if newer versions of Android don't show the group summary text that you design, - // you always need to manually set a summary to enable grouped notifications. - mNotificationManager.notify(SUMMARY_NOTIFICATION_ID, mSummaryNotification); - mNotificationManager.notify(mStatusNotificationId++, mInstallStatusBuilder.build()); - } - @NonNull - @Override - public Result doWork() { - String[] selectedFiles = getInputData().getStringArray("CIA_FILES"); - assert selectedFiles != null; - final CharSequence toastText = mContext.getResources().getQuantityString(R.plurals.cia_install_toast, - selectedFiles.length, selectedFiles.length); - - getApplicationContext().getMainExecutor().execute(() -> Toast.makeText(mContext, toastText, - Toast.LENGTH_LONG).show()); - - // Issue the initial notification with zero progress - mInstallProgressBuilder.setOngoing(true); - setProgressCallback(100, 0); - - int i = 0; - for (String file : selectedFiles) { - String filename = FileUtil.getFilename(Uri.parse(file)); - mInstallProgressBuilder.setContentText(mContext.getString( - R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length)); - InstallStatus res = installCIA(file); - notifyInstallStatus(filename, res); - } - mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID); - - return Result.success(); - } - public void setProgressCallback(int max, int progress) { - long currentTime = System.currentTimeMillis(); - // Android applies a rate limit when updating a notification. - // If you post updates to a single notification too frequently, - // such as many in less than one second, the system might drop updates. - // TODO: consider moving to C++ side - if (currentTime - mLastNotifiedTime < 500 /* ms */){ - return; - } - mLastNotifiedTime = currentTime; - mInstallProgressBuilder.setProgress(max, progress, false); - mNotificationManager.notify(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build()); - } - - @NonNull - @Override - public ForegroundInfo getForegroundInfo() { - return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build()); - } - - private native InstallStatus installCIA(String path); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.kt new file mode 100644 index 000000000..dfb10d310 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.kt @@ -0,0 +1,168 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.app.NotificationManager +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import androidx.work.Worker +import androidx.work.WorkerParameters +import org.citra.citra_emu.NativeLibrary.InstallStatus +import org.citra.citra_emu.R +import org.citra.citra_emu.utils.FileUtil.getFilename + +class CiaInstallWorker( + val context: Context, + params: WorkerParameters +) : Worker(context, params) { + private val GROUP_KEY_CIA_INSTALL_STATUS = "org.citra.citra_emu.CIA_INSTALL_STATUS" + private var lastNotifiedTime: Long = 0 + private val SUMMARY_NOTIFICATION_ID = 0xC1A0000 + private val PROGRESS_NOTIFICATION_ID = SUMMARY_NOTIFICATION_ID + 1 + private var statusNotificationId = SUMMARY_NOTIFICATION_ID + 2 + + private val notificationManager = context.getSystemService(NotificationManager::class.java) + private val installProgressBuilder = NotificationCompat.Builder( + context, + context.getString(R.string.cia_install_notification_channel_id) + ) + .setContentTitle(context.getString(R.string.install_cia_title)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + private val installStatusBuilder = NotificationCompat.Builder( + context, + context.getString(R.string.cia_install_notification_channel_id) + ) + .setContentTitle(context.getString(R.string.install_cia_title)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setGroup(GROUP_KEY_CIA_INSTALL_STATUS) + private val summaryNotification = NotificationCompat.Builder( + context, + context.getString(R.string.cia_install_notification_channel_id) + ) + .setContentTitle(context.getString(R.string.install_cia_title)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setGroup(GROUP_KEY_CIA_INSTALL_STATUS) + .setGroupSummary(true) + .build() + + private fun notifyInstallStatus(filename: String, status: InstallStatus) { + when (status) { + InstallStatus.Success -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_success_title) + ) + installStatusBuilder.setContentText( + context.getString(R.string.cia_install_success, filename) + ) + } + + InstallStatus.ErrorAborted -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_error_title) + ) + installStatusBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.cia_install_error_aborted, filename)) + ) + } + + InstallStatus.ErrorInvalid -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_error_title) + ) + installStatusBuilder.setContentText( + context.getString(R.string.cia_install_error_invalid, filename) + ) + } + + InstallStatus.ErrorEncrypted -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_error_title) + ) + installStatusBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.cia_install_error_encrypted, filename)) + ) + } + + InstallStatus.ErrorFailedToOpenFile, InstallStatus.ErrorFileNotFound -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_error_title) + ) + installStatusBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.cia_install_error_unknown, filename)) + ) + } + + else -> { + installStatusBuilder.setContentTitle( + context.getString(R.string.cia_install_notification_error_title) + ) + installStatusBuilder.setStyle( + NotificationCompat.BigTextStyle() + .bigText(context.getString(R.string.cia_install_error_unknown, filename)) + ) + } + } + + // Even if newer versions of Android don't show the group summary text that you design, + // you always need to manually set a summary to enable grouped notifications. + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) + notificationManager.notify(statusNotificationId++, installStatusBuilder.build()) + } + + override fun doWork(): Result { + val selectedFiles = inputData.getStringArray("CIA_FILES")!! + val toastText: CharSequence = context.resources.getQuantityString( + R.plurals.cia_install_toast, + selectedFiles.size, selectedFiles.size + ) + context.mainExecutor.execute { + Toast.makeText(context, toastText, Toast.LENGTH_LONG).show() + } + + // Issue the initial notification with zero progress + installProgressBuilder.setOngoing(true) + setProgressCallback(100, 0) + selectedFiles.forEachIndexed { i, file -> + val filename = getFilename(Uri.parse(file)) + installProgressBuilder.setContentText( + context.getString( + R.string.cia_install_notification_installing, + filename, + i, + selectedFiles.size + ) + ) + val res = installCIA(file) + notifyInstallStatus(filename, res) + } + notificationManager.cancel(PROGRESS_NOTIFICATION_ID) + return Result.success() + } + + fun setProgressCallback(max: Int, progress: Int) { + val currentTime = System.currentTimeMillis() + // Android applies a rate limit when updating a notification. + // If you post updates to a single notification too frequently, + // such as many in less than one second, the system might drop updates. + // TODO: consider moving to C++ side + if (currentTime - lastNotifiedTime < 500 /* ms */) { + return + } + lastNotifiedTime = currentTime + installProgressBuilder.setProgress(max, progress, false) + notificationManager.notify(PROGRESS_NOTIFICATION_ID, installProgressBuilder.build()) + } + + override fun getForegroundInfo(): ForegroundInfo = + ForegroundInfo(PROGRESS_NOTIFICATION_ID, installProgressBuilder.build()) + + private external fun installCIA(path: String): InstallStatus +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java deleted file mode 100644 index cbdc0742c..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; - -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; - -import java.util.ArrayList; -import java.util.List; - -public final class FileBrowserHelper { - - @Nullable - public static String[] getSelectedFiles(Intent result, Context context, List extension) { - ClipData clipData = result.getClipData(); - List files = new ArrayList<>(); - if (clipData == null) { - files.add(DocumentFile.fromSingleUri(context, result.getData())); - } else { - for (int i = 0; i < clipData.getItemCount(); i++) { - ClipData.Item item = clipData.getItemAt(i); - Uri uri = item.getUri(); - files.add(DocumentFile.fromSingleUri(context, uri)); - } - } - if (!files.isEmpty()) { - List filePaths = new ArrayList<>(); - for (int i = 0; i < files.size(); i++) { - DocumentFile file = files.get(i); - String filename = file.getName(); - int extensionStart = filename.lastIndexOf('.'); - if (extensionStart > 0) { - String fileExtension = filename.substring(extensionStart + 1); - if (extension.contains(fileExtension)) { - filePaths.add(file.getUri().toString()); - } - } - } - if (filePaths.isEmpty()) { - return null; - } - return filePaths.toArray(new String[0]); - } - - return null; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.kt new file mode 100644 index 000000000..423507173 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.kt @@ -0,0 +1,44 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.Context +import android.content.Intent +import androidx.documentfile.provider.DocumentFile + +object FileBrowserHelper { + fun getSelectedFiles( + result: Intent, + context: Context, + extension: List + ): Array? { + val clipData = result.clipData + val files: MutableList = ArrayList() + if (clipData == null) { + files.add(DocumentFile.fromSingleUri(context, result.data!!)) + } else { + for (i in 0 until clipData.itemCount) { + val item = clipData.getItemAt(i) + files.add(DocumentFile.fromSingleUri(context, item.uri)) + } + } + if (files.isNotEmpty()) { + val filePaths: MutableList = ArrayList() + for (i in files.indices) { + val file = files[i] + val filename = file?.name + val extensionStart = filename?.lastIndexOf('.') ?: 0 + if (extensionStart > 0) { + val fileExtension = filename?.substring(extensionStart + 1) + if (extension.contains(fileExtension)) { + filePaths.add(file?.uri.toString()) + } + } + } + return if (filePaths.isEmpty()) null else filePaths.toTypedArray() + } + return null + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java deleted file mode 100644 index 55f8a463e..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.Context; -import android.content.res.Resources; -import android.view.ViewGroup; - -import androidx.core.graphics.Insets; - -import com.google.android.material.appbar.AppBarLayout; - -public class InsetsHelper { - public static final int THREE_BUTTON_NAVIGATION = 0; - public static final int TWO_BUTTON_NAVIGATION = 1; - public static final int GESTURE_NAVIGATION = 2; - - public static void insetAppBar(Insets insets, AppBarLayout appBarLayout) - { - ViewGroup.MarginLayoutParams mlpAppBar = - (ViewGroup.MarginLayoutParams) appBarLayout.getLayoutParams(); - mlpAppBar.leftMargin = insets.left; - mlpAppBar.rightMargin = insets.right; - appBarLayout.setLayoutParams(mlpAppBar); - } - - public static int getSystemGestureType(Context context) { - Resources resources = context.getResources(); - int resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android"); - if (resourceId != 0) { - return resources.getInteger(resourceId); - } - return 0; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.kt new file mode 100644 index 000000000..96ea234e6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/InsetsHelper.kt @@ -0,0 +1,25 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.annotation.SuppressLint +import android.content.Context + +object InsetsHelper { + const val THREE_BUTTON_NAVIGATION = 0 + const val TWO_BUTTON_NAVIGATION = 1 + const val GESTURE_NAVIGATION = 2 + + @SuppressLint("DiscouragedApi") + fun getSystemGestureType(context: Context): Int { + val resources = context.resources + val resourceId = resources.getIdentifier( + "config_navBarInteractionMode", + "integer", + "android" + ) + return if (resourceId != 0) resources.getInteger(resourceId) else 0 + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java deleted file mode 100644 index 096332422..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.citra.citra_emu.utils; - -import org.citra.citra_emu.BuildConfig; - -/** - * Contains methods that call through to {@link android.util.Log}, but - * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log - * levels in release builds. - */ -public final class Log { - // Tracks whether we should share the old log or the current log - public static boolean gameLaunched = false; - - private static final String TAG = "Citra Frontend"; - - private Log() { - } - - public static void verbose(String message) { - if (BuildConfig.DEBUG) { - android.util.Log.v(TAG, message); - } - } - - public static void debug(String message) { - if (BuildConfig.DEBUG) { - android.util.Log.d(TAG, message); - } - } - - public static void info(String message) { - android.util.Log.i(TAG, message); - } - - public static void warning(String message) { - android.util.Log.w(TAG, message); - } - - public static void error(String message) { - android.util.Log.e(TAG, message); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.kt new file mode 100644 index 000000000..26c41bc98 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.util.Log +import org.citra.citra_emu.BuildConfig + +/** + * Contains methods that call through to [android.util.Log], but + * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log + * levels in release builds. + */ +object Log { + // Tracks whether we should share the old log or the current log + var gameLaunched = false + private const val TAG = "Citra Frontend" + + fun verbose(message: String?) { + if (BuildConfig.DEBUG) { + Log.v(TAG, message!!) + } + } + + fun debug(message: String?) { + if (BuildConfig.DEBUG) { + Log.d(TAG, message!!) + } + } + + fun info(message: String?) = Log.i(TAG, message!!) + + fun warning(message: String?) = Log.w(TAG, message!!) + + fun error(message: String?) = Log.e(TAG, message!!) +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java deleted file mode 100644 index 74e282beb..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.graphics.Bitmap; -import android.net.Uri; - -import com.squareup.picasso.Picasso; - -import java.io.IOException; - -import androidx.annotation.Nullable; - -public class PicassoUtils { - // Blocking call. Load image from file and crop/resize it to fit in width x height. - @Nullable - public static Bitmap LoadBitmapFromFile(String uri, int width, int height) { - try { - return Picasso.get() - .load(Uri.parse(uri)) - .config(Bitmap.Config.ARGB_8888) - .centerCrop() - .resize(width, height) - .get(); - } catch (IOException e) { - return null; - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java deleted file mode 100644 index 50dbcbe18..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.citra.citra_emu.viewholders; - -import android.view.View; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.recyclerview.widget.RecyclerView; - -import org.citra.citra_emu.R; - -/** - * A simple class that stores references to views so that the GameAdapter doesn't need to - * keep calling findViewById(), which is expensive. - */ -public class GameViewHolder extends RecyclerView.ViewHolder { - private View itemView; - public ImageView imageIcon; - public TextView textGameTitle; - public TextView textCompany; - public TextView textFileName; - - public String gameId; - - // TODO Not need any of this stuff. Currently only the properties dialog needs it. - public String path; - public String title; - public String description; - public String regions; - public String company; - - public GameViewHolder(View itemView) { - super(itemView); - - this.itemView = itemView; - itemView.setTag(this); - - imageIcon = itemView.findViewById(R.id.image_game_screen); - textGameTitle = itemView.findViewById(R.id.text_game_title); - textCompany = itemView.findViewById(R.id.text_company); - textFileName = itemView.findViewById(R.id.text_filename); - } - - public View getItemView() { - return itemView; - } -} diff --git a/src/android/app/src/main/jni/applets/mii_selector.cpp b/src/android/app/src/main/jni/applets/mii_selector.cpp index 2bb48db9a..3a9c66ef8 100644 --- a/src/android/app/src/main/jni/applets/mii_selector.cpp +++ b/src/android/app/src/main/jni/applets/mii_selector.cpp @@ -23,14 +23,13 @@ void AndroidMiiSelector::Setup(const Frontend::MiiSelectorConfig& config) { // Create the Java MiiSelectorConfig object jobject java_config = env->AllocObject(s_mii_selector_config_class); env->SetBooleanField(java_config, - env->GetFieldID(s_mii_selector_config_class, "enable_cancel_button", "Z"), + env->GetFieldID(s_mii_selector_config_class, "enableCancelButton", "Z"), static_cast(config.enable_cancel_button)); env->SetObjectField(java_config, env->GetFieldID(s_mii_selector_config_class, "title", "Ljava/lang/String;"), ToJString(env, config.title)); env->SetLongField( - java_config, - env->GetFieldID(s_mii_selector_config_class, "initially_selected_mii_index", "J"), + java_config, env->GetFieldID(s_mii_selector_config_class, "initiallySelectedMiiIndex", "J"), static_cast(config.initially_selected_mii_index)); // List mii names @@ -44,14 +43,14 @@ void AndroidMiiSelector::Setup(const Frontend::MiiSelectorConfig& config) { } env->SetObjectField( java_config, - env->GetFieldID(s_mii_selector_config_class, "mii_names", "[Ljava/lang/String;"), array); + env->GetFieldID(s_mii_selector_config_class, "miiNames", "[Ljava/lang/String;"), array); // Invoke backend Execute method jobject data = env->CallStaticObjectMethod(s_mii_selector_class, s_mii_selector_execute, java_config); const u32 return_code = static_cast( - env->GetLongField(data, env->GetFieldID(s_mii_selector_data_class, "return_code", "J"))); + env->GetLongField(data, env->GetFieldID(s_mii_selector_data_class, "returnCode", "J"))); if (return_code == 1) { Finalize(return_code, Mii::MiiData{}); return; diff --git a/src/android/app/src/main/jni/applets/swkbd.cpp b/src/android/app/src/main/jni/applets/swkbd.cpp index e373c9e27..0a8337672 100644 --- a/src/android/app/src/main/jni/applets/swkbd.cpp +++ b/src/android/app/src/main/jni/applets/swkbd.cpp @@ -23,14 +23,14 @@ namespace SoftwareKeyboard { static jobject ToJavaKeyboardConfig(const Frontend::KeyboardConfig& config) { JNIEnv* env = IDCache::GetEnvForThread(); jobject object = env->AllocObject(s_keyboard_config_class); - env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "button_config", "I"), + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "buttonConfig", "I"), static_cast(config.button_config)); - env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "max_text_length", "I"), + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "maxTextLength", "I"), static_cast(config.max_text_length)); - env->SetBooleanField(object, env->GetFieldID(s_keyboard_config_class, "multiline_mode", "Z"), + env->SetBooleanField(object, env->GetFieldID(s_keyboard_config_class, "multilineMode", "Z"), static_cast(config.multiline_mode)); env->SetObjectField(object, - env->GetFieldID(s_keyboard_config_class, "hint_text", "Ljava/lang/String;"), + env->GetFieldID(s_keyboard_config_class, "hintText", "Ljava/lang/String;"), ToJString(env, config.hint_text)); const jclass string_class = reinterpret_cast(env->FindClass("java/lang/String")); @@ -42,7 +42,7 @@ static jobject ToJavaKeyboardConfig(const Frontend::KeyboardConfig& config) { ToJString(env, config.button_text[i])); } env->SetObjectField( - object, env->GetFieldID(s_keyboard_config_class, "button_text", "[Ljava/lang/String;"), + object, env->GetFieldID(s_keyboard_config_class, "buttonText", "[Ljava/lang/String;"), array); return object; diff --git a/src/android/app/src/main/res/drawable/button_home.xml b/src/android/app/src/main/res/drawable/button_home.xml new file mode 100644 index 000000000..c6797da12 --- /dev/null +++ b/src/android/app/src/main/res/drawable/button_home.xml @@ -0,0 +1,16 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/button_home_pressed.xml b/src/android/app/src/main/res/drawable/button_home_pressed.xml new file mode 100644 index 000000000..45878eae9 --- /dev/null +++ b/src/android/app/src/main/res/drawable/button_home_pressed.xml @@ -0,0 +1,16 @@ + + + + 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 index ec9942cc5..1ea85d56a 100644 --- 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 @@ -1,37 +1,41 @@ - + android:nextFocusLeft="@id/cheat_switch"> - + app:layout_constraintTop_toTopOf="parent" + tools:ignore="RtlSymmetry" /> diff --git a/src/android/app/src/main/res/layout/activity_cheats.xml b/src/android/app/src/main/res/layout/activity_cheats.xml index 0d2a92f68..2155f7c58 100644 --- a/src/android/app/src/main/res/layout/activity_cheats.xml +++ b/src/android/app/src/main/res/layout/activity_cheats.xml @@ -1,60 +1,26 @@ - + android:layout_height="match_parent"> - + android:layout_height="match_parent" + android:keepScreenOn="true" + app:defaultNavHost="true" /> - - - - - - - - - - - - - - - + app:layout_constraintStart_toStartOf="parent" /> diff --git a/src/android/app/src/main/res/layout/card_home_option.xml b/src/android/app/src/main/res/layout/card_home_option.xml index 6457c02c8..d18d17d9b 100644 --- a/src/android/app/src/main/res/layout/card_home_option.xml +++ b/src/android/app/src/main/res/layout/card_home_option.xml @@ -6,8 +6,8 @@ android:id="@+id/option_card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginVertical="12dp" - android:layout_marginHorizontal="16dp" + android:layout_marginBottom="24dp" + android:layout_marginHorizontal="12dp" android:background="?attr/selectableItemBackground" android:backgroundTint="?attr/colorSurfaceVariant" android:clickable="true" @@ -16,7 +16,8 @@ + android:layout_height="wrap_content" + android:layout_gravity="center_vertical"> + tools:visibility="visible" + tools:text="/tree/primary:Games" /> diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml index 456084449..7bd26f246 100644 --- a/src/android/app/src/main/res/layout/fragment_about.xml +++ b/src/android/app/src/main/res/layout/fragment_about.xml @@ -38,8 +38,8 @@ 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 index 25b1a268a..dd16dd9be 100644 --- a/src/android/app/src/main/res/layout/fragment_cheat_details.xml +++ b/src/android/app/src/main/res/layout/fragment_cheat_details.xml @@ -1,163 +1,177 @@ - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> - + android:layout_height="wrap_content" + android:fitsSystemWindows="true"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +