From daf36fd56055e0807df90d3257a7c5f08988fe6c Mon Sep 17 00:00:00 2001 From: bunnei Date: Sat, 17 Dec 2022 23:25:46 -0800 Subject: [PATCH] android: Add Citra frontend. --- src/android/.gitignore | 62 ++ src/android/app/build.gradle | 163 ++++ src/android/app/proguard-rules.pro | 21 + .../citra_emu/ExampleInstrumentedTest.java | 3 + src/android/app/src/main/AndroidManifest.xml | 99 ++ .../org/citra/citra_emu/CitraApplication.java | 56 ++ .../org/citra/citra_emu/NativeLibrary.java | 631 +++++++++++++ .../activities/CustomFilePickerActivity.java | 38 + .../activities/EmulationActivity.java | 755 +++++++++++++++ .../citra/citra_emu/adapters/GameAdapter.java | 247 +++++ .../citra/citra_emu/applets/MiiSelector.java | 122 +++ .../citra_emu/applets/SoftwareKeyboard.java | 264 ++++++ .../camera/StillImageCameraHelper.java | 65 ++ .../citra_emu/dialogs/MotionAlertDialog.java | 140 +++ .../DiskShaderCacheProgress.java | 138 +++ .../features/cheats/model/Cheat.java | 57 ++ .../features/cheats/model/CheatEngine.java | 13 + .../cheats/model/CheatsViewModel.java | 177 ++++ .../cheats/ui/CheatDetailsFragment.java | 174 ++++ .../features/cheats/ui/CheatListFragment.java | 46 + .../features/cheats/ui/CheatViewHolder.java | 56 ++ .../features/cheats/ui/CheatsActivity.java | 161 ++++ .../features/cheats/ui/CheatsAdapter.java | 72 ++ .../settings/model/BooleanSetting.java | 23 + .../features/settings/model/FloatSetting.java | 23 + .../features/settings/model/IntSetting.java | 23 + .../features/settings/model/Setting.java | 42 + .../settings/model/SettingSection.java | 55 ++ .../features/settings/model/Settings.java | 132 +++ .../settings/model/StringSetting.java | 23 + .../settings/model/view/CheckBoxSetting.java | 80 ++ .../settings/model/view/DateTimeSetting.java | 40 + .../settings/model/view/HeaderSetting.java | 14 + .../model/view/InputBindingSetting.java | 382 ++++++++ .../settings/model/view/PremiumHeader.java | 12 + .../view/PremiumSingleChoiceSetting.java | 59 ++ .../settings/model/view/SettingsItem.java | 107 +++ .../model/view/SingleChoiceSetting.java | 60 ++ .../settings/model/view/SliderSetting.java | 101 ++ .../model/view/StringSingleChoiceSetting.java | 82 ++ .../settings/model/view/SubmenuSetting.java | 21 + .../settings/ui/SettingsActivity.java | 215 +++++ .../ui/SettingsActivityPresenter.java | 124 +++ .../settings/ui/SettingsActivityView.java | 103 ++ .../features/settings/ui/SettingsAdapter.java | 487 ++++++++++ .../settings/ui/SettingsFragment.java | 136 +++ .../ui/SettingsFragmentPresenter.java | 416 +++++++++ .../settings/ui/SettingsFragmentView.java | 78 ++ .../settings/ui/SettingsFrameLayout.java | 48 + .../viewholder/CheckBoxSettingViewHolder.java | 54 ++ .../ui/viewholder/DateTimeViewHolder.java | 47 + .../ui/viewholder/HeaderViewHolder.java | 32 + .../InputBindingSettingViewHolder.java | 55 ++ .../ui/viewholder/PremiumViewHolder.java | 57 ++ .../ui/viewholder/SettingViewHolder.java | 49 + .../ui/viewholder/SingleChoiceViewHolder.java | 76 ++ .../ui/viewholder/SliderViewHolder.java | 45 + .../ui/viewholder/SubmenuViewHolder.java | 45 + .../features/settings/utils/SettingsFile.java | 341 +++++++ .../fragments/CustomFilePickerFragment.java | 120 +++ .../fragments/EmulationFragment.java | 380 ++++++++ .../java/org/citra/citra_emu/model/Game.java | 76 ++ .../citra/citra_emu/model/GameDatabase.java | 276 ++++++ .../citra/citra_emu/model/GameProvider.java | 138 +++ .../citra/citra_emu/overlay/InputOverlay.java | 878 ++++++++++++++++++ .../overlay/InputOverlayDrawableButton.java | 122 +++ .../overlay/InputOverlayDrawableDpad.java | 193 ++++ .../overlay/InputOverlayDrawableJoystick.java | 264 ++++++ .../citra_emu/ui/DividerItemDecoration.java | 130 +++ .../ui/TwoPaneOnBackPressedCallback.java | 37 + .../citra/citra_emu/ui/main/MainActivity.java | 267 ++++++ .../citra_emu/ui/main/MainPresenter.java | 82 ++ .../org/citra/citra_emu/ui/main/MainView.java | 25 + .../ui/platform/PlatformGamesFragment.java | 86 ++ .../ui/platform/PlatformGamesPresenter.java | 42 + .../ui/platform/PlatformGamesView.java | 21 + .../org/citra/citra_emu/utils/Action1.java | 5 + .../citra_emu/utils/AddDirectoryHelper.java | 38 + .../java/org/citra/citra_emu/utils/BiMap.java | 22 + .../citra/citra_emu/utils/BillingManager.java | 215 +++++ .../utils/ControllerMappingHelper.java | 66 ++ .../utils/DirectoryInitialization.java | 186 ++++ .../utils/DirectoryStateReceiver.java | 22 + .../utils/EmulationMenuSettings.java | 78 ++ .../citra_emu/utils/FileBrowserHelper.java | 73 ++ .../org/citra/citra_emu/utils/FileUtil.java | 37 + .../citra_emu/utils/ForegroundService.java | 63 ++ .../utils/GameIconRequestHandler.java | 27 + .../java/org/citra/citra_emu/utils/Log.java | 39 + .../citra_emu/utils/PermissionsHandler.java | 35 + .../PicassoRoundedCornersTransformation.java | 45 + .../citra/citra_emu/utils/PicassoUtils.java | 57 ++ .../citra/citra_emu/utils/StartupHandler.java | 45 + .../org/citra/citra_emu/utils/ThemeUtil.java | 34 + .../citra_emu/viewholders/GameViewHolder.java | 46 + .../src/main/res/animator/settings_enter.xml | 28 + .../src/main/res/animator/settings_exit.xml | 28 + .../main/res/animator/settings_pop_enter.xml | 28 + .../main/res/animator/setttings_pop_exit.xml | 27 + .../src/main/res/drawable-hdpi/button_a.png | Bin 0 -> 10674 bytes .../res/drawable-hdpi/button_a_pressed.png | Bin 0 -> 10738 bytes .../src/main/res/drawable-hdpi/button_b.png | Bin 0 -> 9479 bytes .../res/drawable-hdpi/button_b_pressed.png | Bin 0 -> 9555 bytes .../src/main/res/drawable-hdpi/button_l.png | Bin 0 -> 2738 bytes .../res/drawable-hdpi/button_l_pressed.png | Bin 0 -> 2795 bytes .../src/main/res/drawable-hdpi/button_r.png | Bin 0 -> 5680 bytes .../res/drawable-hdpi/button_r_pressed.png | Bin 0 -> 5784 bytes .../main/res/drawable-hdpi/button_select.png | Bin 0 -> 13280 bytes .../drawable-hdpi/button_select_pressed.png | Bin 0 -> 13344 bytes .../main/res/drawable-hdpi/button_start.png | Bin 0 -> 9518 bytes .../drawable-hdpi/button_start_pressed.png | Bin 0 -> 14872 bytes .../src/main/res/drawable-hdpi/button_x.png | Bin 0 -> 12124 bytes .../res/drawable-hdpi/button_x_pressed.png | Bin 0 -> 12390 bytes .../src/main/res/drawable-hdpi/button_y.png | Bin 0 -> 9321 bytes .../res/drawable-hdpi/button_y_pressed.png | Bin 0 -> 9498 bytes .../src/main/res/drawable-hdpi/button_zl.png | Bin 0 -> 4423 bytes .../res/drawable-hdpi/button_zl_pressed.png | Bin 0 -> 4426 bytes .../src/main/res/drawable-hdpi/button_zr.png | Bin 0 -> 6239 bytes .../res/drawable-hdpi/button_zr_pressed.png | Bin 0 -> 6201 bytes .../app/src/main/res/drawable-hdpi/dpad.png | Bin 0 -> 4273 bytes .../dpad_pressed_one_direction.png | Bin 0 -> 3824 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 5658 bytes .../main/res/drawable-hdpi/ic_cia_install.png | Bin 0 -> 514 bytes .../src/main/res/drawable-hdpi/ic_folder.png | Bin 0 -> 275 bytes .../src/main/res/drawable-hdpi/ic_premium.png | Bin 0 -> 961 bytes .../res/drawable-hdpi/ic_settings_core.png | Bin 0 -> 793 bytes .../ic_stat_notification_logo.png | Bin 0 -> 2824 bytes .../src/main/res/drawable-hdpi/stick_c.png | Bin 0 -> 14819 bytes .../res/drawable-hdpi/stick_c_pressed.png | Bin 0 -> 14825 bytes .../main/res/drawable-hdpi/stick_c_range.png | Bin 0 -> 8813 bytes .../src/main/res/drawable-hdpi/stick_main.png | Bin 0 -> 12828 bytes .../res/drawable-hdpi/stick_main_pressed.png | Bin 0 -> 8244 bytes .../res/drawable-hdpi/stick_main_range.png | Bin 0 -> 32592 bytes .../main/res/drawable-mdpi/ic_cia_install.png | Bin 0 -> 364 bytes .../src/main/res/drawable-mdpi/ic_folder.png | Bin 0 -> 214 bytes .../src/main/res/drawable-mdpi/ic_premium.png | Bin 0 -> 605 bytes .../drawable-night-hdpi/ic_cia_install.png | Bin 0 -> 556 bytes .../res/drawable-night-hdpi/ic_folder.png | Bin 0 -> 289 bytes .../res/drawable-night-hdpi/ic_premium.png | Bin 0 -> 955 bytes .../drawable-night-hdpi/ic_settings_core.png | Bin 0 -> 1152 bytes .../drawable-night-mdpi/ic_cia_install.png | Bin 0 -> 405 bytes .../res/drawable-night-mdpi/ic_folder.png | Bin 0 -> 227 bytes .../res/drawable-night-mdpi/ic_premium.png | Bin 0 -> 595 bytes .../drawable-night-xhdpi/ic_cia_install.png | Bin 0 -> 729 bytes .../res/drawable-night-xhdpi/ic_folder.png | Bin 0 -> 347 bytes .../res/drawable-night-xhdpi/ic_premium.png | Bin 0 -> 1281 bytes .../drawable-night-xhdpi/ic_settings_core.png | Bin 0 -> 1431 bytes .../drawable-night-xxhdpi/ic_cia_install.png | Bin 0 -> 1168 bytes .../res/drawable-night-xxhdpi/ic_folder.png | Bin 0 -> 555 bytes .../res/drawable-night-xxhdpi/ic_premium.png | Bin 0 -> 2049 bytes .../ic_settings_core.png | Bin 0 -> 2125 bytes .../drawable-night-xxxhdpi/ic_cia_install.png | Bin 0 -> 1433 bytes .../res/drawable-night-xxxhdpi/ic_folder.png | Bin 0 -> 657 bytes .../res/drawable-night-xxxhdpi/ic_premium.png | Bin 0 -> 2614 bytes .../ic_settings_core.png | Bin 0 -> 2587 bytes .../src/main/res/drawable-night/no_icon.png | Bin 0 -> 9238 bytes .../src/main/res/drawable-xhdpi/button_a.png | Bin 0 -> 14645 bytes .../res/drawable-xhdpi/button_a_pressed.png | Bin 0 -> 14643 bytes .../src/main/res/drawable-xhdpi/button_b.png | Bin 0 -> 13040 bytes .../res/drawable-xhdpi/button_b_pressed.png | Bin 0 -> 13046 bytes .../src/main/res/drawable-xhdpi/button_l.png | Bin 0 -> 3461 bytes .../res/drawable-xhdpi/button_l_pressed.png | Bin 0 -> 3471 bytes .../src/main/res/drawable-xhdpi/button_r.png | Bin 0 -> 7603 bytes .../res/drawable-xhdpi/button_r_pressed.png | Bin 0 -> 7595 bytes .../main/res/drawable-xhdpi/button_select.png | Bin 0 -> 17681 bytes .../drawable-xhdpi/button_select_pressed.png | Bin 0 -> 17648 bytes .../main/res/drawable-xhdpi/button_start.png | Bin 0 -> 19588 bytes .../drawable-xhdpi/button_start_pressed.png | Bin 0 -> 19743 bytes .../src/main/res/drawable-xhdpi/button_x.png | Bin 0 -> 16315 bytes .../res/drawable-xhdpi/button_x_pressed.png | Bin 0 -> 16543 bytes .../src/main/res/drawable-xhdpi/button_y.png | Bin 0 -> 12529 bytes .../res/drawable-xhdpi/button_y_pressed.png | Bin 0 -> 12698 bytes .../src/main/res/drawable-xhdpi/button_zl.png | Bin 0 -> 5584 bytes .../res/drawable-xhdpi/button_zl_pressed.png | Bin 0 -> 5616 bytes .../src/main/res/drawable-xhdpi/button_zr.png | Bin 0 -> 8283 bytes .../res/drawable-xhdpi/button_zr_pressed.png | Bin 0 -> 8330 bytes .../app/src/main/res/drawable-xhdpi/dpad.png | Bin 0 -> 5296 bytes .../dpad_pressed_one_direction.png | Bin 0 -> 4781 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 7857 bytes .../res/drawable-xhdpi/ic_cia_install.png | Bin 0 -> 656 bytes .../src/main/res/drawable-xhdpi/ic_folder.png | Bin 0 -> 325 bytes .../main/res/drawable-xhdpi/ic_premium.png | Bin 0 -> 1334 bytes .../res/drawable-xhdpi/ic_settings_core.png | Bin 0 -> 1029 bytes .../ic_stat_notification_logo.png | Bin 0 -> 4026 bytes .../src/main/res/drawable-xhdpi/stick_c.png | Bin 0 -> 23215 bytes .../res/drawable-xhdpi/stick_c_pressed.png | Bin 0 -> 20594 bytes .../main/res/drawable-xhdpi/stick_c_range.png | Bin 0 -> 18277 bytes .../main/res/drawable-xhdpi/stick_main.png | Bin 0 -> 19086 bytes .../res/drawable-xhdpi/stick_main_pressed.png | Bin 0 -> 11657 bytes .../res/drawable-xhdpi/stick_main_range.png | Bin 0 -> 53646 bytes .../src/main/res/drawable-xxhdpi/button_a.png | Bin 0 -> 23552 bytes .../res/drawable-xxhdpi/button_a_pressed.png | Bin 0 -> 23611 bytes .../src/main/res/drawable-xxhdpi/button_b.png | Bin 0 -> 20371 bytes .../res/drawable-xxhdpi/button_b_pressed.png | Bin 0 -> 20591 bytes .../src/main/res/drawable-xxhdpi/button_l.png | Bin 0 -> 5288 bytes .../res/drawable-xxhdpi/button_l_pressed.png | Bin 0 -> 5352 bytes .../src/main/res/drawable-xxhdpi/button_r.png | Bin 0 -> 11960 bytes .../res/drawable-xxhdpi/button_r_pressed.png | Bin 0 -> 11969 bytes .../res/drawable-xxhdpi/button_select.png | Bin 0 -> 27251 bytes .../drawable-xxhdpi/button_select_pressed.png | Bin 0 -> 27436 bytes .../main/res/drawable-xxhdpi/button_start.png | Bin 0 -> 30505 bytes .../drawable-xxhdpi/button_start_pressed.png | Bin 0 -> 30785 bytes .../src/main/res/drawable-xxhdpi/button_x.png | Bin 0 -> 27021 bytes .../res/drawable-xxhdpi/button_x_pressed.png | Bin 0 -> 27645 bytes .../src/main/res/drawable-xxhdpi/button_y.png | Bin 0 -> 19978 bytes .../res/drawable-xxhdpi/button_y_pressed.png | Bin 0 -> 20426 bytes .../main/res/drawable-xxhdpi/button_zl.png | Bin 0 -> 8675 bytes .../res/drawable-xxhdpi/button_zl_pressed.png | Bin 0 -> 8675 bytes .../main/res/drawable-xxhdpi/button_zr.png | Bin 0 -> 13105 bytes .../res/drawable-xxhdpi/button_zr_pressed.png | Bin 0 -> 13182 bytes .../app/src/main/res/drawable-xxhdpi/dpad.png | Bin 0 -> 7816 bytes .../dpad_pressed_one_direction.png | Bin 0 -> 6977 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 12762 bytes .../res/drawable-xxhdpi/ic_cia_install.png | Bin 0 -> 967 bytes .../main/res/drawable-xxhdpi/ic_folder.png | Bin 0 -> 487 bytes .../main/res/drawable-xxhdpi/ic_premium.png | Bin 0 -> 2096 bytes .../res/drawable-xxhdpi/ic_settings_core.png | Bin 0 -> 1647 bytes .../ic_stat_notification_logo.png | Bin 0 -> 5936 bytes .../src/main/res/drawable-xxhdpi/stick_c.png | Bin 0 -> 41218 bytes .../res/drawable-xxhdpi/stick_c_pressed.png | Bin 0 -> 32729 bytes .../res/drawable-xxhdpi/stick_c_range.png | Bin 0 -> 28519 bytes .../main/res/drawable-xxhdpi/stick_main.png | Bin 0 -> 35658 bytes .../drawable-xxhdpi/stick_main_pressed.png | Bin 0 -> 19150 bytes .../res/drawable-xxhdpi/stick_main_range.png | Bin 0 -> 99656 bytes .../main/res/drawable-xxxhdpi/button_a.png | Bin 0 -> 29133 bytes .../res/drawable-xxxhdpi/button_a_pressed.png | Bin 0 -> 29190 bytes .../main/res/drawable-xxxhdpi/button_b.png | Bin 0 -> 24653 bytes .../res/drawable-xxxhdpi/button_b_pressed.png | Bin 0 -> 24931 bytes .../main/res/drawable-xxxhdpi/button_l.png | Bin 0 -> 6396 bytes .../res/drawable-xxxhdpi/button_l_pressed.png | Bin 0 -> 6455 bytes .../main/res/drawable-xxxhdpi/button_r.png | Bin 0 -> 14580 bytes .../res/drawable-xxxhdpi/button_r_pressed.png | Bin 0 -> 14493 bytes .../res/drawable-xxxhdpi/button_select.png | Bin 0 -> 32098 bytes .../button_select_pressed.png | Bin 0 -> 32299 bytes .../res/drawable-xxxhdpi/button_start.png | Bin 0 -> 36683 bytes .../drawable-xxxhdpi/button_start_pressed.png | Bin 0 -> 36775 bytes .../main/res/drawable-xxxhdpi/button_x.png | Bin 0 -> 33016 bytes .../res/drawable-xxxhdpi/button_x_pressed.png | Bin 0 -> 34053 bytes .../main/res/drawable-xxxhdpi/button_y.png | Bin 0 -> 24127 bytes .../res/drawable-xxxhdpi/button_y_pressed.png | Bin 0 -> 24408 bytes .../main/res/drawable-xxxhdpi/button_zl.png | Bin 0 -> 10479 bytes .../drawable-xxxhdpi/button_zl_pressed.png | Bin 0 -> 10484 bytes .../main/res/drawable-xxxhdpi/button_zr.png | Bin 0 -> 15653 bytes .../drawable-xxxhdpi/button_zr_pressed.png | Bin 0 -> 15648 bytes .../src/main/res/drawable-xxxhdpi/dpad.png | Bin 0 -> 9253 bytes .../dpad_pressed_one_direction.png | Bin 0 -> 8434 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 16159 bytes .../res/drawable-xxxhdpi/ic_cia_install.png | Bin 0 -> 1244 bytes .../main/res/drawable-xxxhdpi/ic_folder.png | Bin 0 -> 591 bytes .../main/res/drawable-xxxhdpi/ic_premium.png | Bin 0 -> 2654 bytes .../res/drawable-xxxhdpi/ic_settings_core.png | Bin 0 -> 2093 bytes .../src/main/res/drawable-xxxhdpi/stick_c.png | Bin 0 -> 57013 bytes .../res/drawable-xxxhdpi/stick_c_pressed.png | Bin 0 -> 40273 bytes .../res/drawable-xxxhdpi/stick_c_range.png | Bin 0 -> 34281 bytes .../main/res/drawable-xxxhdpi/stick_main.png | Bin 0 -> 45881 bytes .../drawable-xxxhdpi/stick_main_pressed.png | Bin 0 -> 24942 bytes .../res/drawable-xxxhdpi/stick_main_range.png | Bin 0 -> 136109 bytes .../main/res/drawable/gamelist_divider.xml | 11 + .../app/src/main/res/drawable/ic_add.xml | 9 + .../app/src/main/res/drawable/no_icon.png | Bin 0 -> 8610 bytes .../main/res/layout-ldrtl/list_item_cheat.xml | 38 + .../src/main/res/layout/activity_cheats.xml | 22 + .../main/res/layout/activity_emulation.xml | 17 + .../app/src/main/res/layout/activity_main.xml | 27 + .../src/main/res/layout/activity_settings.xml | 5 + .../app/src/main/res/layout/card_game.xml | 81 ++ .../src/main/res/layout/dialog_checkbox.xml | 16 + .../main/res/layout/dialog_progress_bar.xml | 26 + .../src/main/res/layout/dialog_seekbar.xml | 37 + .../main/res/layout/filepicker_toolbar.xml | 32 + .../res/layout/fragment_cheat_details.xml | 163 ++++ .../main/res/layout/fragment_cheat_list.xml | 27 + .../main/res/layout/fragment_emulation.xml | 47 + .../app/src/main/res/layout/fragment_grid.xml | 33 + .../src/main/res/layout/fragment_settings.xml | 12 + .../src/main/res/layout/list_item_cheat.xml | 38 + .../src/main/res/layout/list_item_setting.xml | 43 + .../res/layout/list_item_setting_checkbox.xml | 52 ++ .../res/layout/list_item_settings_header.xml | 19 + .../main/res/layout/premium_item_setting.xml | 43 + .../res/layout/sysclock_datetime_picker.xml | 22 + .../app/src/main/res/menu/menu_emulation.xml | 118 +++ .../app/src/main/res/menu/menu_game_grid.xml | 34 + .../app/src/main/res/menu/menu_settings.xml | 2 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5899 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7416 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3377 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 4413 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8742 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 10530 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 14300 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 17511 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 20804 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 24886 bytes .../app/src/main/res/values-night/colors.xml | 17 + .../res/values-night/styles_filepicker.xml | 5 + .../src/main/res/values-w1000dp/integers.xml | 4 + .../src/main/res/values-w1050dp/dimens.xml | 6 + .../src/main/res/values-w500dp/integers.xml | 4 + .../src/main/res/values-w750dp/integers.xml | 4 + .../app/src/main/res/values-w820dp/dimens.xml | 5 + .../app/src/main/res/values/arrays.xml | 174 ++++ .../app/src/main/res/values/colors.xml | 17 + .../app/src/main/res/values/dimens.xml | 10 + .../res/values/ic_launcher_background.xml | 4 + .../app/src/main/res/values/integers.xml | 65 ++ .../app/src/main/res/values/strings.xml | 246 +++++ .../app/src/main/res/values/styles.xml | 65 ++ .../src/main/res/values/styles_filepicker.xml | 5 + .../org/citra/citra_emu/ExampleUnitTest.java | 17 + src/android/build.gradle | 26 + src/android/code-style-java.xml | 240 +++++ src/android/gradle.properties | 15 + src/android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54708 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + src/android/gradlew | 172 ++++ src/android/gradlew.bat | 84 ++ src/android/settings.gradle | 1 + 319 files changed, 13799 insertions(+) create mode 100644 src/android/.gitignore create mode 100644 src/android/app/build.gradle create mode 100644 src/android/app/proguard-rules.pro create mode 100644 src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java create mode 100644 src/android/app/src/main/AndroidManifest.xml create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java create 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/SoftwareKeyboard.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java create 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/CheatEngine.java create 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/ui/CheatDetailsFragment.java create 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/CheatViewHolder.java create 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/CheatsAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/Game.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java create 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/InputOverlayDrawableButton.java create 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/InputOverlayDrawableJoystick.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java create 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/main/MainActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java create 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/BillingManager.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java create 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/FileUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java create 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/PermissionsHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java create mode 100644 src/android/app/src/main/res/animator/settings_enter.xml create mode 100644 src/android/app/src/main/res/animator/settings_exit.xml create mode 100644 src/android/app/src/main/res/animator/settings_pop_enter.xml create mode 100644 src/android/app/src/main/res/animator/setttings_pop_exit.xml create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night/no_icon.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable/gamelist_divider.xml create mode 100644 src/android/app/src/main/res/drawable/ic_add.xml create mode 100644 src/android/app/src/main/res/drawable/no_icon.png create mode 100644 src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml create mode 100644 src/android/app/src/main/res/layout/activity_cheats.xml create mode 100644 src/android/app/src/main/res/layout/activity_emulation.xml create mode 100644 src/android/app/src/main/res/layout/activity_main.xml create mode 100644 src/android/app/src/main/res/layout/activity_settings.xml create mode 100644 src/android/app/src/main/res/layout/card_game.xml create mode 100644 src/android/app/src/main/res/layout/dialog_checkbox.xml create mode 100644 src/android/app/src/main/res/layout/dialog_progress_bar.xml create mode 100644 src/android/app/src/main/res/layout/dialog_seekbar.xml create mode 100644 src/android/app/src/main/res/layout/filepicker_toolbar.xml create mode 100644 src/android/app/src/main/res/layout/fragment_cheat_details.xml create mode 100644 src/android/app/src/main/res/layout/fragment_cheat_list.xml create mode 100644 src/android/app/src/main/res/layout/fragment_emulation.xml create mode 100644 src/android/app/src/main/res/layout/fragment_grid.xml create mode 100644 src/android/app/src/main/res/layout/fragment_settings.xml create mode 100644 src/android/app/src/main/res/layout/list_item_cheat.xml create mode 100644 src/android/app/src/main/res/layout/list_item_setting.xml create mode 100644 src/android/app/src/main/res/layout/list_item_setting_checkbox.xml create mode 100644 src/android/app/src/main/res/layout/list_item_settings_header.xml create mode 100644 src/android/app/src/main/res/layout/premium_item_setting.xml create mode 100644 src/android/app/src/main/res/layout/sysclock_datetime_picker.xml create mode 100644 src/android/app/src/main/res/menu/menu_emulation.xml create mode 100644 src/android/app/src/main/res/menu/menu_game_grid.xml create mode 100644 src/android/app/src/main/res/menu/menu_settings.xml create mode 100644 src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/values-night/colors.xml create mode 100644 src/android/app/src/main/res/values-night/styles_filepicker.xml create mode 100644 src/android/app/src/main/res/values-w1000dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w1050dp/dimens.xml create mode 100644 src/android/app/src/main/res/values-w500dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w750dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w820dp/dimens.xml create mode 100644 src/android/app/src/main/res/values/arrays.xml create mode 100644 src/android/app/src/main/res/values/colors.xml create mode 100644 src/android/app/src/main/res/values/dimens.xml create mode 100644 src/android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 src/android/app/src/main/res/values/integers.xml create mode 100644 src/android/app/src/main/res/values/strings.xml create mode 100644 src/android/app/src/main/res/values/styles.xml create mode 100644 src/android/app/src/main/res/values/styles_filepicker.xml create mode 100644 src/android/app/src/test/java/org/citra/citra_emu/ExampleUnitTest.java create mode 100644 src/android/build.gradle create mode 100644 src/android/code-style-java.xml create mode 100644 src/android/gradle.properties create mode 100644 src/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 src/android/gradlew create mode 100644 src/android/gradlew.bat create mode 100644 src/android/settings.gradle diff --git a/src/android/.gitignore b/src/android/.gitignore new file mode 100644 index 0000000000..40b6c5cd05 --- /dev/null +++ b/src/android/.gitignore @@ -0,0 +1,62 @@ +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +# Uncomment the following line if you do not want to check your keystore files in. +#*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# CXX compile cache +app/.cxx + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle new file mode 100644 index 0000000000..5a108743b6 --- /dev/null +++ b/src/android/app/build.gradle @@ -0,0 +1,163 @@ +apply plugin: 'com.android.application' + +/** + * Use the number of seconds/10 since Jan 1 2016 as the versionCode. + * This lets us upload a new build at most every 10 seconds for the + * next 680 years. + */ +def autoVersion = (int) (((new Date().getTime() / 1000) - 1451606400) / 10) +def buildType +def abiFilter = "arm64-v8a" //, "x86" + +android { + compileSdkVersion 32 + ndkVersion "25.1.8937393" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + // This is important as it will run lint but not abort on error + // Lint has some overly obnoxious "errors" that should really be warnings + abortOnError false + + //Uncomment disable lines for test builds... + //disable 'MissingTranslation'bin + //disable 'ExtraTranslation' + } + + defaultConfig { + // TODO If this is ever modified, change application_id in strings.xml + applicationId "org.citra.citra_emu" + minSdkVersion 28 + targetSdkVersion 29 + versionCode autoVersion + versionName getVersion() + ndk.abiFilters abiFilter + } + + signingConfigs { + //release { + // storeFile file('') + // storePassword System.getenv('ANDROID_KEYPASS') + // keyAlias = 'key0' + // keyPassword System.getenv('ANDROID_KEYPASS') + //} + } + + applicationVariants.all { variant -> + buildType = variant.buildType.name // sets the current build type + } + + // Define build types, which are orthogonal to product flavors. + buildTypes { + + // Signed by release key, allowing for upload to Play Store. + release { + signingConfig signingConfigs.debug + } + + // builds a release build that doesn't need signing + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + relWithDebInfo { + initWith release + applicationIdSuffix ".debug" + versionNameSuffix '-debug' + signingConfig signingConfigs.debug + minifyEnabled false + testCoverageEnabled false + debuggable true + jniDebuggable true + } + + // Signed by debug key disallowing distribution on Play Store. + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + debug { + // TODO If this is ever modified, change application_id in debug/strings.xml + applicationIdSuffix ".debug" + versionNameSuffix '-debug' + debuggable true + jniDebuggable true + } + } + + flavorDimensions "version" + productFlavors { + canary { + dimension "version" + applicationIdSuffix ".canary" + } + nightly { + dimension "version" + } + } + + externalNativeBuild { + cmake { + version "3.22.1" + path "../../../CMakeLists.txt" + } + } + + defaultConfig { + externalNativeBuild { + cmake { + arguments "-DENABLE_QT=0", // Don't use QT + "-DENABLE_SDL2=0", // Don't use SDL + "-DENABLE_WEB_SERVICE=0", // Don't use telemetry + "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work + "-DYUZU_USE_BUNDLED_VCPKG=ON", + "-DYUZU_USE_BUNDLED_FFMPEG=ON" + + abiFilters abiFilter + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.exifinterface:exifinterface:1.3.4' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1' + implementation 'androidx.fragment:fragment:1.5.3' + implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" + implementation 'com.google.android.material:material:1.6.1' + + // For loading huge screenshots from the disk. + implementation 'com.squareup.picasso:picasso:2.71828' + + // Allows FRP-style asynchronous operations in Android. + implementation 'io.reactivex:rxandroid:1.2.1' + implementation 'com.nononsenseapps:filepicker:4.2.1' + implementation 'org.ini4j:ini4j:0.5.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + + // Please don't upgrade the billing library as the newer version is not GPL-compatible + implementation 'com.android.billingclient:billing:2.0.3' +} + +def getVersion() { + def versionName = '0.0' + + try { + versionName = 'git describe --always --long'.execute([], project.rootDir).text + .trim() + .replaceAll(/(-0)?-[^-]+$/, "") + } catch (Exception) { + logger.error('Cannot find git, defaulting to dummy version number') + } + + if (System.getenv("GITHUB_ACTIONS") != null) { + def gitTag = System.getenv("GIT_TAG_NAME") + versionName = gitTag ?: versionName + } + + return versionName +} diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/src/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java new file mode 100644 index 0000000000..6a25f2ce6a --- /dev/null +++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java @@ -0,0 +1,3 @@ +package org.citra.citra_emu; + +import android.content.Context; diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..c2463e079a --- /dev/null +++ b/src/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java new file mode 100644 index 0000000000..41ac7e27c0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java @@ -0,0 +1,56 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.PermissionsHandler; + +public class CitraApplication extends Application { + public static GameDatabase databaseHelper; + private static CitraApplication application; + + private void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.app_notification_channel_name); + String description = getString(R.string.app_notification_channel_description); + NotificationChannel channel = new NotificationChannel(getString(R.string.app_notification_channel_id), name, NotificationManager.IMPORTANCE_LOW); + channel.setDescription(description); + channel.setSound(null, null); + channel.setVibrationPattern(null); + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + @Override + public void onCreate() { + super.onCreate(); + application = this; + + if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { + DirectoryInitialization.start(getApplicationContext()); + } + + NativeLibrary.LogDeviceInfo(); + createNotificationChannel(); + + databaseHelper = new GameDatabase(this); + } + + public static Context getAppContext() { + return application.getApplicationContext(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java new file mode 100644 index 0000000000..baff99dc8b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java @@ -0,0 +1,631 @@ +/* + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu; + +import android.app.Activity; +import android.app.Dialog; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.Bundle; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.view.Surface; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; + +/** + * Class which contains methods that interact + * with the native side of the Citra code. + */ +public final class NativeLibrary { + /** + * Default touchscreen device + */ + public static final String TouchScreenDevice = "Touchscreen"; + public static WeakReference sEmulationActivity = new WeakReference<>(null); + + private static boolean alertResult = false; + private static String alertPromptResult = ""; + private static int alertPromptButton = 0; + private static final Object alertPromptLock = new Object(); + private static boolean alertPromptInProgress = false; + private static String alertPromptCaption = ""; + private static int alertPromptButtonConfig = 0; + private static EditText alertPromptEditText = null; + + static { + try { + System.loadLibrary("yuzu-android"); + } catch (UnsatisfiedLinkError ex) { + Log.error("[NativeLibrary] " + ex.toString()); + } + } + + private NativeLibrary() { + // Disallows instantiation. + } + + /** + * Handles button press events for a gamepad. + * + * @param Device The input descriptor of the gamepad. + * @param Button Key code identifying which button was pressed. + * @param Action Mask identifying which action is happening (button pressed down, or button released). + * @return If we handled the button press. + */ + public static native boolean onGamePadEvent(String Device, int Button, int Action); + + /** + * Handles gamepad movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis The axis ID + * @param x_axis The value of the x-axis represented by the given ID. + * @param y_axis The value of the y-axis represented by the given ID + */ + public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis); + + /** + * Handles gamepad movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis_id The axis ID + * @param axis_val The value of the axis represented by the given ID. + */ + public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val); + + /** + * Handles touch events. + * + * @param x_axis The value of the x-axis. + * @param y_axis The value of the y-axis + * @param pressed To identify if the touch held down or released. + * @return true if the pointer is within the touchscreen + */ + public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed); + + /** + * Handles touch movement. + * + * @param x_axis The value of the instantaneous x-axis. + * @param y_axis The value of the instantaneous y-axis. + */ + public static native void onTouchMoved(float x_axis, float y_axis); + + public static native void ReloadSettings(); + + public static native String GetUserSetting(String gameID, String Section, String Key); + + public static native void SetUserSetting(String gameID, String Section, String Key, String Value); + + public static native void InitGameIni(String gameID); + + /** + * Gets the embedded icon within the given ROM. + * + * @param filename the file path to the ROM. + * @return an integer array containing the color data for the icon. + */ + public static native int[] GetIcon(String filename); + + /** + * Gets the embedded title of the given ISO/ROM. + * + * @param filename The file path to the ISO/ROM. + * @return the embedded title of the ISO/ROM. + */ + public static native String GetTitle(String filename); + + public static native String GetDescription(String filename); + + public static native String GetGameId(String filename); + + public static native String GetRegions(String filename); + + public static native String GetCompany(String filename); + + public static native String GetGitRevision(); + + /** + * Sets the current working user directory + * If not set, it auto-detects a location + */ + public static native void SetUserDirectory(String directory); + + // Create the config.ini file. + public static native void CreateConfigFile(); + + public static native int DefaultCPUCore(); + + /** + * Begins emulation. + */ + public static native void Run(String path); + + /** + * Begins emulation from the specified savestate. + */ + public static native void Run(String path, String savestatePath, boolean deleteSavestate); + + // Surface Handling + public static native void SurfaceChanged(Surface surf); + + public static native void SurfaceDestroyed(); + + public static native void DoFrame(); + + /** + * Unpauses emulation from a paused state. + */ + public static native void UnPauseEmulation(); + + /** + * Pauses emulation. + */ + public static native void PauseEmulation(); + + /** + * Stops emulation. + */ + public static native void StopEmulation(); + + /** + * Returns true if emulation is running (or is paused). + */ + public static native boolean IsRunning(); + + /** + * Returns the performance stats for the current game + **/ + public static native double[] GetPerfStats(); + + /** + * Notifies the core emulation that the orientation has changed. + */ + public static native void NotifyOrientationChange(int layout_option, int rotation); + + public enum CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown, + } + + private static boolean coreErrorAlertResult = false; + private static final Object coreErrorAlertLock = new Object(); + + public static class CoreErrorDialogFragment extends DialogFragment { + static CoreErrorDialogFragment newInstance(String title, String message) { + CoreErrorDialogFragment frag = new CoreErrorDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + return new AlertDialog.Builder(emulationActivity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button, (dialog, which) -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }) + .setNegativeButton(R.string.abort_button, (dialog, which) -> { + coreErrorAlertResult = false; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).setOnDismissListener(dialog -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).create(); + } + } + + private static void OnCoreErrorImpl(String title, String message) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return; + } + + CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message); + fragment.show(emulationActivity.getSupportFragmentManager(), "coreError"); + } + + /** + * Handles a core error. + * @return true: continue; false: abort + */ + public static boolean OnCoreError(CoreError error, String details) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + + String title, message; + switch (error) { + case ErrorSystemFiles: { + title = emulationActivity.getString(R.string.system_archive_not_found); + message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details); + break; + } + case ErrorSavestate: { + title = emulationActivity.getString(R.string.save_load_error); + message = details; + break; + } + case ErrorUnknown: { + title = emulationActivity.getString(R.string.fatal_error); + message = emulationActivity.getString(R.string.fatal_error_message); + break; + } + default: { + return true; + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message)); + + // Wait for the lock to notify that it is complete. + synchronized (coreErrorAlertLock) { + try { + coreErrorAlertLock.wait(); + } catch (Exception ignored) { + } + } + + return coreErrorAlertResult; + } + + public static boolean isPortraitMode() { + return CitraApplication.getAppContext().getResources().getConfiguration().orientation == + Configuration.ORIENTATION_PORTRAIT; + } + + public static int landscapeScreenLayout() { + return EmulationMenuSettings.getLandscapeScreenLayout(); + } + + public static boolean displayAlertMsg(final String caption, final String text, + final boolean yesNo) { + Log.error("[NativeLibrary] Alert: " + text); + final EmulationActivity emulationActivity = sEmulationActivity.get(); + boolean result = false; + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert."); + } else { + // Create object used for waiting. + final Object lock = new Object(); + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setMessage(text); + + // If not yes/no dialog just have one button that dismisses modal, + // otherwise have a yes and no button that sets alertResult accordingly. + if (!yesNo) { + builder + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> + { + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } else { + alertResult = false; + + builder + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> + { + alertResult = true; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }) + .setNegativeButton(android.R.string.no, (dialog, whichButton) -> + { + alertResult = false; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(builder::show); + + // Wait for the lock to notify that it is complete. + synchronized (lock) { + try { + lock.wait(); + } catch (Exception e) { + } + } + + if (yesNo) + result = alertResult; + } + return result; + } + + public static void retryDisplayAlertPrompt() { + if (!alertPromptInProgress) { + return; + } + displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show(); + } + + public static String displayAlertPrompt(String caption, String text, int buttonConfig) { + alertPromptCaption = caption; + alertPromptButtonConfig = buttonConfig; + alertPromptInProgress = true; + + // Show the AlertDialog on the main thread + sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show()); + + // Wait for the lock to notify that it is complete + synchronized (alertPromptLock) { + try { + alertPromptLock.wait(); + } catch (Exception e) { + } + } + alertPromptInProgress = false; + + return alertPromptResult; + } + + public static AlertDialog.Builder displayAlertPromptImpl(String caption, String text, int buttonConfig) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + alertPromptResult = ""; + alertPromptButton = 0; + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin); + + // Set up the input + alertPromptEditText = new EditText(CitraApplication.getAppContext()); + alertPromptEditText.setText(text); + alertPromptEditText.setSingleLine(); + alertPromptEditText.setLayoutParams(params); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(alertPromptEditText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setView(container) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + alertPromptButton = buttonConfig; + alertPromptResult = alertPromptEditText.getText().toString(); + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }) + .setOnDismissListener(dialogInterface -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + + if (buttonConfig > 0) { + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + } + + return builder; + } + + public static int alertPromptButton() { + return alertPromptButton; + } + + public static void exitEmulationActivity(int resultCode) { + final int Success = 0; + final int ErrorNotInitialized = 1; + final int ErrorGetLoader = 2; + final int ErrorSystemMode = 3; + final int ErrorLoader = 4; + final int ErrorLoader_ErrorEncrypted = 5; + final int ErrorLoader_ErrorInvalidFormat = 6; + final int ErrorSystemFiles = 7; + final int ErrorVideoCore = 8; + final int ErrorVideoCore_ErrorGenericDrivers = 9; + final int ErrorVideoCore_ErrorBelowGL33 = 10; + final int ShutdownRequested = 11; + final int ErrorUnknown = 12; + + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit."); + return; + } + + int captionId = R.string.loader_error_invalid_format; + if (resultCode == ErrorLoader_ErrorEncrypted) { + captionId = R.string.loader_error_encrypted; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(captionId) + .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish()) + .setOnDismissListener(dialogInterface -> emulationActivity.finish()); + emulationActivity.runOnUiThread(() -> { + AlertDialog alert = builder.create(); + alert.show(); + ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + }); + } + + public static void setEmulationActivity(EmulationActivity emulationActivity) { + Log.verbose("[NativeLibrary] Registering EmulationActivity."); + sEmulationActivity = new WeakReference<>(emulationActivity); + } + + public static void clearEmulationActivity() { + Log.verbose("[NativeLibrary] Unregistering EmulationActivity."); + + sEmulationActivity.clear(); + } + + private static final Object cameraPermissionLock = new Object(); + private static boolean cameraPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_CAMERA = 800; + + public static boolean RequestCameraPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA); + + // Wait until result is returned + synchronized (cameraPermissionLock) { + try { + cameraPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return cameraPermissionGranted; + } + + public static void CameraPermissionResult(boolean granted) { + cameraPermissionGranted = granted; + synchronized (cameraPermissionLock) { + cameraPermissionLock.notify(); + } + } + + private static final Object micPermissionLock = new Object(); + private static boolean micPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_MIC = 900; + + public static boolean RequestMicPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC); + + // Wait until result is returned + synchronized (micPermissionLock) { + try { + micPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return micPermissionGranted; + } + + public static void MicPermissionResult(boolean granted) { + micPermissionGranted = granted; + synchronized (micPermissionLock) { + micPermissionLock.notify(); + } + } + + /** + * Logs the Citra version, Android version and, CPU. + */ + public static native void LogDeviceInfo(); + + /** + * Button type for use in onTouchEvent + */ + public static final class ButtonType { + public static final int BUTTON_A = 0; + public static final int BUTTON_B = 1; + public static final int BUTTON_X = 2; + public static final int BUTTON_Y = 3; + public static final int BUTTON_START = 11; + public static final int BUTTON_SELECT = 12; + public static final int BUTTON_HOME = 19; + public static final int BUTTON_ZL = 9; + public static final int BUTTON_ZR = 10; + public static final int DPAD_UP = 14; + public static final int DPAD_DOWN = 16; + public static final int DPAD_LEFT = 13; + public static final int DPAD_RIGHT = 15; + public static final int STICK_LEFT = 5; + public static final int STICK_LEFT_UP = 714; + public static final int STICK_LEFT_DOWN = 715; + public static final int STICK_LEFT_LEFT = 716; + public static final int STICK_LEFT_RIGHT = 717; + public static final int STICK_C = 6; + public static final int STICK_C_UP = 719; + public static final int STICK_C_DOWN = 720; + public static final int STICK_C_LEFT = 771; + public static final int STICK_C_RIGHT = 772; + public static final int TRIGGER_L = 7; + public static final int TRIGGER_R = 8; + public static final int DPAD = 780; + public static final int BUTTON_DEBUG = 781; + public static final int BUTTON_GPIO14 = 782; + } + + /** + * Button states + */ + public static final class ButtonState { + public static final int RELEASED = 0; + public static final int PRESSED = 1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java new file mode 100644 index 0000000000..3083286e21 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java @@ -0,0 +1,38 @@ +package org.citra.citra_emu.activities; + +import android.content.Intent; +import android.os.Environment; + +import androidx.annotation.Nullable; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerActivity; + +import org.citra.citra_emu.fragments.CustomFilePickerFragment; + +import java.io.File; + +public class CustomFilePickerActivity extends FilePickerActivity { + public static final String EXTRA_TITLE = "filepicker.intent.TITLE"; + public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS"; + + @Override + protected AbstractFilePickerFragment getFragment( + @Nullable final String startPath, final int mode, final boolean allowMultiple, + final boolean allowCreateDir, final boolean allowExistingFile, + final boolean singleClick) { + CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + // startPath is allowed to be null. In that case, default folder should be SD-card and not "/" + fragment.setArgs( + startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + + Intent intent = getIntent(); + int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0); + fragment.setTitle(title); + String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS); + fragment.setAllowedExtensions(allowedExtensions); + + return fragment; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java new file mode 100644 index 0000000000..47ef0fd23f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java @@ -0,0 +1,755 @@ +package org.citra.citra_emu.activities; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.util.SparseIntArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationManagerCompat; +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.ui.CheatsActivity; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivity; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.camera.StillImageCameraHelper; +import org.citra.citra_emu.fragments.EmulationFragment; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.ControllerMappingHelper; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.FileBrowserHelper; +import org.citra.citra_emu.utils.FileUtil; +import org.citra.citra_emu.utils.ForegroundService; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.util.Collections; +import java.util.List; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +public final class EmulationActivity extends AppCompatActivity { + public static final String EXTRA_SELECTED_GAME = "SelectedGame"; + public static final String EXTRA_SELECTED_TITLE = "SelectedTitle"; + public static final int MENU_ACTION_EDIT_CONTROLS_PLACEMENT = 0; + public static final int MENU_ACTION_TOGGLE_CONTROLS = 1; + public static final int MENU_ACTION_ADJUST_SCALE = 2; + public static final int MENU_ACTION_EXIT = 3; + public static final int MENU_ACTION_SHOW_FPS = 4; + public static final int MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE = 5; + public static final int MENU_ACTION_SCREEN_LAYOUT_PORTRAIT = 6; + public static final int MENU_ACTION_SCREEN_LAYOUT_SINGLE = 7; + public static final int MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE = 8; + public static final int MENU_ACTION_SWAP_SCREENS = 9; + public static final int MENU_ACTION_RESET_OVERLAY = 10; + public static final int MENU_ACTION_SHOW_OVERLAY = 11; + public static final int MENU_ACTION_OPEN_SETTINGS = 12; + public static final int MENU_ACTION_LOAD_AMIIBO = 13; + public static final int MENU_ACTION_REMOVE_AMIIBO = 14; + public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15; + public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16; + public static final int MENU_ACTION_OPEN_CHEATS = 17; + + public static final int REQUEST_SELECT_AMIIBO = 2; + private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; + private static SparseIntArray buttonsActionsMap = new SparseIntArray(); + + static { + buttonsActionsMap.append(R.id.menu_emulation_edit_layout, + EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT); + buttonsActionsMap.append(R.id.menu_emulation_toggle_controls, + EmulationActivity.MENU_ACTION_TOGGLE_CONTROLS); + buttonsActionsMap + .append(R.id.menu_emulation_adjust_scale, EmulationActivity.MENU_ACTION_ADJUST_SCALE); + buttonsActionsMap.append(R.id.menu_emulation_show_fps, + EmulationActivity.MENU_ACTION_SHOW_FPS); + buttonsActionsMap.append(R.id.menu_screen_layout_landscape, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE); + buttonsActionsMap.append(R.id.menu_screen_layout_portrait, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_PORTRAIT); + buttonsActionsMap.append(R.id.menu_screen_layout_single, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SINGLE); + buttonsActionsMap.append(R.id.menu_screen_layout_sidebyside, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE); + buttonsActionsMap.append(R.id.menu_emulation_swap_screens, + EmulationActivity.MENU_ACTION_SWAP_SCREENS); + buttonsActionsMap + .append(R.id.menu_emulation_reset_overlay, EmulationActivity.MENU_ACTION_RESET_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_show_overlay, EmulationActivity.MENU_ACTION_SHOW_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_open_settings, EmulationActivity.MENU_ACTION_OPEN_SETTINGS); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_load, EmulationActivity.MENU_ACTION_LOAD_AMIIBO); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_remove, EmulationActivity.MENU_ACTION_REMOVE_AMIIBO); + buttonsActionsMap.append(R.id.menu_emulation_joystick_rel_center, + EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER); + buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable, + EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE); + buttonsActionsMap + .append(R.id.menu_emulation_open_cheats, EmulationActivity.MENU_ACTION_OPEN_CHEATS); + } + + private View mDecorView; + private EmulationFragment mEmulationFragment; + private SharedPreferences mPreferences; + private ControllerMappingHelper mControllerMappingHelper; + private Intent foregroundService; + private boolean activityRecreated; + private String mSelectedTitle; + private String mPath; + + public static void launch(FragmentActivity activity, String path, String title) { + Intent launcher = new Intent(activity, EmulationActivity.class); + + launcher.putExtra(EXTRA_SELECTED_GAME, path); + launcher.putExtra(EXTRA_SELECTED_TITLE, title); + activity.startActivity(launcher); + } + + public static void tryDismissRunningNotification(Activity activity) { + NotificationManagerCompat.from(activity).cancel(EMULATION_RUNNING_NOTIFICATION); + } + + @Override + protected void onDestroy() { + stopService(foregroundService); + super.onDestroy(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState == null) { + // Get params we were passed + Intent gameToEmulate = getIntent(); + mPath = gameToEmulate.getStringExtra(EXTRA_SELECTED_GAME); + mSelectedTitle = gameToEmulate.getStringExtra(EXTRA_SELECTED_TITLE); + activityRecreated = false; + } else { + activityRecreated = true; + restoreState(savedInstanceState); + } + + mControllerMappingHelper = new ControllerMappingHelper(); + + // Get a handle to the Window containing the UI. + mDecorView = getWindow().getDecorView(); + mDecorView.setOnSystemUiVisibilityChangeListener(visibility -> + { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + // Go back to immersive fullscreen mode in 3s + Handler handler = new Handler(getMainLooper()); + handler.postDelayed(this::enableFullscreenImmersive, 3000 /* 3s */); + } + }); + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive(); + + setTheme(R.style.CitraEmulationBase); + + setContentView(R.layout.activity_emulation); + + // Find or create the EmulationFragment + mEmulationFragment = (EmulationFragment) getSupportFragmentManager() + .findFragmentById(R.id.frame_emulation_fragment); + if (mEmulationFragment == null) { + mEmulationFragment = EmulationFragment.newInstance(mPath); + getSupportFragmentManager().beginTransaction() + .add(R.id.frame_emulation_fragment, mEmulationFragment) + .commit(); + } + + setTitle(mSelectedTitle); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(this); + + // Start a foreground service to prevent the app from getting killed in the background + foregroundService = new Intent(EmulationActivity.this, ForegroundService.class); + startForegroundService(foregroundService); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + outState.putString(EXTRA_SELECTED_GAME, mPath); + outState.putString(EXTRA_SELECTED_TITLE, mSelectedTitle); + super.onSaveInstanceState(outState); + } + + protected void restoreState(Bundle savedInstanceState) { + mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); + mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); + + // If an alert prompt was in progress when state was restored, retry displaying it + NativeLibrary.retryDisplayAlertPrompt(); + } + + @Override + public void onRestart() { + super.onRestart(); + } + + @Override + public void onBackPressed() { + NativeLibrary.PauseEmulation(); + new AlertDialog.Builder(this) + .setTitle(R.string.emulation_close_game) + .setMessage(R.string.emulation_close_game_message) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> + { + mEmulationFragment.stopEmulation(); + finish(); + }) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + NativeLibrary.UnPauseEmulation()) + .setOnCancelListener(dialogInterface -> + NativeLibrary.UnPauseEmulation()) + .create() + .show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case NativeLibrary.REQUEST_CODE_NATIVE_CAMERA: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(CAMERA)) { + new AlertDialog.Builder(this) + .setTitle(R.string.camera) + .setMessage(R.string.camera_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + case NativeLibrary.REQUEST_CODE_NATIVE_MIC: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(RECORD_AUDIO)) { + new AlertDialog.Builder(this) + .setTitle(R.string.microphone) + .setMessage(R.string.microphone_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + break; + } + } + + private void enableFullscreenImmersive() { + // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar. + mDecorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_emulation, menu); + + int layoutOptionMenuItem = R.id.menu_screen_layout_landscape; + switch (EmulationMenuSettings.getLandscapeScreenLayout()) { + case EmulationMenuSettings.LayoutOption_SingleScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_single; + break; + case EmulationMenuSettings.LayoutOption_SideScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_sidebyside; + break; + case EmulationMenuSettings.LayoutOption_MobilePortrait: + layoutOptionMenuItem = R.id.menu_screen_layout_portrait; + break; + } + + menu.findItem(layoutOptionMenuItem).setChecked(true); + menu.findItem(R.id.menu_emulation_joystick_rel_center).setChecked(EmulationMenuSettings.getJoystickRelCenter()); + menu.findItem(R.id.menu_emulation_dpad_slide_enable).setChecked(EmulationMenuSettings.getDpadSlideEnable()); + menu.findItem(R.id.menu_emulation_show_fps).setChecked(EmulationMenuSettings.getShowFps()); + menu.findItem(R.id.menu_emulation_swap_screens).setChecked(EmulationMenuSettings.getSwapScreens()); + menu.findItem(R.id.menu_emulation_show_overlay).setChecked(EmulationMenuSettings.getShowOverlay()); + + return true; + } + + private void DisplaySavestateWarning() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + if (preferences.getBoolean("savestateWarningShown", false)) { + return; + } + + LayoutInflater inflater = mEmulationFragment.requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_checkbox, null); + CheckBox checkBox = view.findViewById(R.id.checkBox); + + new AlertDialog.Builder(this) + .setTitle(R.string.savestate_warning_title) + .setMessage(R.string.savestate_warning_message) + .setView(view) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + preferences.edit().putBoolean("savestateWarningShown", checkBox.isChecked()).apply(); + }) + .show(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_emulation_save_state).setVisible(false); + menu.findItem(R.id.menu_emulation_load_state).setVisible(false); + return true; + } + + @SuppressWarnings("WrongConstant") + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int action = buttonsActionsMap.get(item.getItemId(), -1); + + switch (action) { + // Edit the placement of the controls + case MENU_ACTION_EDIT_CONTROLS_PLACEMENT: + editControlsPlacement(); + break; + + // Enable/Disable specific buttons or the entire input overlay. + case MENU_ACTION_TOGGLE_CONTROLS: + toggleControls(); + break; + + // Adjust the scale of the overlay controls. + case MENU_ACTION_ADJUST_SCALE: + adjustScale(); + break; + + // Toggle the visibility of the Performance stats TextView + case MENU_ACTION_SHOW_FPS: { + final boolean isEnabled = !EmulationMenuSettings.getShowFps(); + EmulationMenuSettings.setShowFps(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.updateShowFpsOverlay(); + break; + } + // Sets the screen layout to Landscape + case MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, item); + break; + + // Sets the screen layout to Portrait + case MENU_ACTION_SCREEN_LAYOUT_PORTRAIT: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, item); + break; + + // Sets the screen layout to Single + case MENU_ACTION_SCREEN_LAYOUT_SINGLE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, item); + break; + + // Sets the screen layout to Side by Side + case MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, item); + break; + + // Swap the top and bottom screen locations + case MENU_ACTION_SWAP_SCREENS: { + final boolean isEnabled = !EmulationMenuSettings.getSwapScreens(); + EmulationMenuSettings.setSwapScreens(isEnabled); + item.setChecked(isEnabled); + break; + } + + // Reset overlay placement + case MENU_ACTION_RESET_OVERLAY: + resetOverlay(); + break; + + // Show or hide overlay + case MENU_ACTION_SHOW_OVERLAY: { + final boolean isEnabled = !EmulationMenuSettings.getShowOverlay(); + EmulationMenuSettings.setShowOverlay(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.refreshInputOverlay(); + break; + } + + case MENU_ACTION_EXIT: + mEmulationFragment.stopEmulation(); + finish(); + break; + + case MENU_ACTION_OPEN_SETTINGS: + SettingsActivity.launch(this, SettingsFile.FILE_NAME_CONFIG, ""); + break; + + case MENU_ACTION_LOAD_AMIIBO: + FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO, + R.string.select_amiibo, + Collections.singletonList("bin"), false); + break; + + case MENU_ACTION_REMOVE_AMIIBO: + RemoveAmiibo(); + break; + + case MENU_ACTION_JOYSTICK_REL_CENTER: + final boolean isJoystickRelCenterEnabled = !EmulationMenuSettings.getJoystickRelCenter(); + EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled); + item.setChecked(isJoystickRelCenterEnabled); + break; + + case MENU_ACTION_DPAD_SLIDE_ENABLE: + final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable(); + EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled); + item.setChecked(isDpadSlideEnabled); + break; + + case MENU_ACTION_OPEN_CHEATS: + CheatsActivity.launch(this); + break; + } + + return true; + } + + private void changeScreenOrientation(int layoutOption, MenuItem item) { + item.setChecked(true); + NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() + .getRotation()); + EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); + } + + private void editControlsPlacement() { + if (mEmulationFragment.isConfiguringControls()) { + mEmulationFragment.stopConfiguringControls(); + } else { + mEmulationFragment.startConfiguringControls(); + } + } + + // Gets button presses + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int action; + int button = mPreferences.getInt(InputBindingSetting.getInputButtonKey(event.getKeyCode()), event.getKeyCode()); + + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Handling the case where the back button is pressed. + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + return true; + } + + // Normal key events. + action = NativeLibrary.ButtonState.PRESSED; + break; + case KeyEvent.ACTION_UP: + action = NativeLibrary.ButtonState.RELEASED; + break; + default: + return false; + } + InputDevice input = event.getDevice(); + + if (input == null) { + // Controller was disconnected + return false; + } + + return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent result) { + super.onActivityResult(requestCode, resultCode, result); + switch (requestCode) { + case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER: + StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); + break; + case REQUEST_SELECT_AMIIBO: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result); + if (selectedFiles == null) + return; + + onAmiiboSelected(selectedFiles[0]); + } + break; + } + } + + private void onAmiiboSelected(String selectedFile) { + File file = new File(selectedFile); + boolean success = false; + try { + byte[] bytes = FileUtil.getBytesFromFile(file); + } catch (IOException e) { + e.printStackTrace(); + } + + if (!success) { + new AlertDialog.Builder(this) + .setTitle(R.string.amiibo_load_error) + .setMessage(R.string.amiibo_load_error_message) + .setPositiveButton(android.R.string.ok, null) + .create() + .show(); + } + } + + private void RemoveAmiibo() { + + } + + private void toggleControls() { + final SharedPreferences.Editor editor = mPreferences.edit(); + boolean[] enabledButtons = new boolean[14]; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_toggle_controls); + + for (int i = 0; i < enabledButtons.length; i++) { + // Buttons that are disabled by default + boolean defaultValue = true; + switch (i) { + case 6: // ZL + case 7: // ZR + case 12: // C-stick + defaultValue = false; + break; + } + + enabledButtons[i] = mPreferences.getBoolean("buttonToggle" + i, defaultValue); + } + builder.setMultiChoiceItems(R.array.n3dsButtons, enabledButtons, + (dialog, indexSelected, isChecked) -> editor + .putBoolean("buttonToggle" + indexSelected, isChecked)); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + editor.apply(); + + mEmulationFragment.refreshInputOverlay(); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void adjustScale() { + LayoutInflater inflater = LayoutInflater.from(this); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + final SeekBar seekbar = view.findViewById(R.id.seekbar); + final TextView value = view.findViewById(R.id.text_value); + final TextView units = view.findViewById(R.id.text_units); + + seekbar.setMax(150); + seekbar.setProgress(mPreferences.getInt("controlScale", 50)); + seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + value.setText(String.valueOf(progress + 50)); + } + + public void onStopTrackingTouch(SeekBar seekBar) { + setControlScale(seekbar.getProgress()); + } + }); + + value.setText(String.valueOf(seekbar.getProgress() + 50)); + units.setText("%"); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_control_scale); + builder.setView(view); + final int previousProgress = seekbar.getProgress(); + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + setControlScale(previousProgress); + }); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + setControlScale(seekbar.getProgress()); + }); + builder.setNeutralButton(R.string.slider_default, (dialogInterface, i) -> { + setControlScale(50); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void setControlScale(int scale) { + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", scale); + editor.apply(); + mEmulationFragment.refreshInputOverlay(); + } + + private void resetOverlay() { + new AlertDialog.Builder(this) + .setTitle(getString(R.string.emulation_touch_overlay_reset)) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> mEmulationFragment.resetInputOverlay()) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + }) + .create() + .show(); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)) { + return super.dispatchGenericMotionEvent(event); + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + return true; + } + + InputDevice input = event.getDevice(); + List motions = input.getMotionRanges(); + + float[] axisValuesCirclePad = {0.0f, 0.0f}; + float[] axisValuesCStick = {0.0f, 0.0f}; + float[] axisValuesDPad = {0.0f, 0.0f}; + boolean isTriggerPressedLMapped = false; + boolean isTriggerPressedRMapped = false; + boolean isTriggerPressedZLMapped = false; + boolean isTriggerPressedZRMapped = false; + boolean isTriggerPressedL = false; + boolean isTriggerPressedR = false; + boolean isTriggerPressedZL = false; + boolean isTriggerPressedZR = false; + + for (InputDevice.MotionRange range : motions) { + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = mControllerMappingHelper.scaleAxis(input, axis, origValue); + int nextMapping = mPreferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1); + int guestOrientation = mPreferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1); + + if (nextMapping == -1 || guestOrientation == -1) { + // Axis is unmapped + continue; + } + + if ((value > 0.f && value < 0.1f) || (value < 0.f && value > -0.1f)) { + // Skip joystick wobble + value = 0.f; + } + + if (nextMapping == NativeLibrary.ButtonType.STICK_LEFT) { + axisValuesCirclePad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.STICK_C) { + axisValuesCStick[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.DPAD) { + axisValuesDPad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_L) { + isTriggerPressedLMapped = true; + isTriggerPressedL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_R) { + isTriggerPressedRMapped = true; + isTriggerPressedR = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZL) { + isTriggerPressedZLMapped = true; + isTriggerPressedZL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZR) { + isTriggerPressedZRMapped = true; + isTriggerPressedZR = value != 0.f; + } + } + + // Circle-Pad and C-Stick status + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); + + // Triggers L/R and ZL/ZR + if (isTriggerPressedLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + + // Work-around to allow D-pad axis to be bound to emulated buttons + if (axisValuesDPad[0] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); + } + if (axisValuesDPad[1] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); + } + + return true; + } + + public boolean isActivityRecreated() { + return activityRecreated; + } + + @Retention(SOURCE) + @IntDef({MENU_ACTION_EDIT_CONTROLS_PLACEMENT, MENU_ACTION_TOGGLE_CONTROLS, MENU_ACTION_ADJUST_SCALE, + MENU_ACTION_EXIT, MENU_ACTION_SHOW_FPS, MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE, + MENU_ACTION_SCREEN_LAYOUT_PORTRAIT, MENU_ACTION_SCREEN_LAYOUT_SINGLE, MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE, + MENU_ACTION_SWAP_SCREENS, MENU_ACTION_RESET_OVERLAY, MENU_ACTION_SHOW_OVERLAY, MENU_ACTION_OPEN_SETTINGS}) + public @interface MenuAction { + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java new file mode 100644 index 0000000000..bc791638a1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java @@ -0,0 +1,247 @@ +package org.citra.citra_emu.adapters; + +import android.database.Cursor; +import android.database.DataSetObserver; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.ui.DividerItemDecoration; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.PicassoUtils; +import org.citra.citra_emu.viewholders.GameViewHolder; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +/** + * This adapter gets its information from a database Cursor. This fact, paired with the usage of + * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly) + * large dataset. + */ +public final class GameAdapter extends RecyclerView.Adapter implements + View.OnClickListener { + private Cursor mCursor; + private GameDataSetObserver mObserver; + + private boolean mDatasetValid; + private long mLastClickTime = 0; + + /** + * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will + * display no data until a Cursor is supplied by a CursorLoader. + */ + public GameAdapter() { + mDatasetValid = false; + mObserver = new GameDataSetObserver(); + } + + /** + * Called by the LayoutManager when it is necessary to create a new view. + * + * @param parent The RecyclerView (I think?) the created view will be thrown into. + * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView. + * @return The created ViewHolder with references to all the child view's members. + */ + @Override + public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + // Create a new view. + View gameCard = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.card_game, parent, false); + + gameCard.setOnClickListener(this); + + // Use that view to create a ViewHolder. + return new GameViewHolder(gameCard); + } + + /** + * Called by the LayoutManager when a new view is not necessary because we can recycle + * an existing one (for example, if a view just scrolled onto the screen from the bottom, we + * can use the view that just scrolled off the top instead of inflating a new one.) + * + * @param holder A ViewHolder representing the view we're recycling. + * @param position The position of the 'new' view in the dataset. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void onBindViewHolder(@NonNull GameViewHolder holder, int position) { + if (mDatasetValid) { + if (mCursor.moveToPosition(position)) { + PicassoUtils.loadGameIcon(holder.imageIcon, + mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + + holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); + holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + + final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + holder.textFileName.setText(gamePath.getFileName().toString()); + + // TODO These shouldn't be necessary once the move to a DB-based model is complete. + holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); + holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); + holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE); + holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION); + holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS); + holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY); + + final int backgroundColorId = isValidGame(holder.path) ? R.color.card_view_background : R.color.card_view_disabled; + View itemView = holder.getItemView(); + itemView.setBackgroundColor(ContextCompat.getColor(itemView.getContext(), backgroundColorId)); + } else { + Log.error("[GameAdapter] Can't bind view; Cursor is not valid."); + } + } else { + Log.error("[GameAdapter] Can't bind view; dataset is not valid."); + } + } + + /** + * Called by the LayoutManager to find out how much data we have. + * + * @return Size of the dataset. + */ + @Override + public int getItemCount() { + if (mDatasetValid && mCursor != null) { + return mCursor.getCount(); + } + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Return the contents of the _id column for a given row. + * + * @param position The row for which Android wants an ID. + * @return A valid ID from the database, or 0 if not available. + */ + @Override + public long getItemId(int position) { + if (mDatasetValid && mCursor != null) { + if (mCursor.moveToPosition(position)) { + return mCursor.getLong(GameDatabase.COLUMN_DB_ID); + } + } + + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Tell Android whether or not each item in the dataset has a stable identifier. + * Which it does, because it's a database, so always tell Android 'true'. + * + * @param hasStableIds ignored. + */ + @Override + public void setHasStableIds(boolean hasStableIds) { + super.setHasStableIds(true); + } + + /** + * When a load is finished, call this to replace the existing data with the newly-loaded + * data. + * + * @param cursor The newly-loaded Cursor. + */ + public void swapCursor(Cursor cursor) { + // Sanity check. + if (cursor == mCursor) { + return; + } + + // Before getting rid of the old cursor, disassociate it from the Observer. + final Cursor oldCursor = mCursor; + if (oldCursor != null && mObserver != null) { + oldCursor.unregisterDataSetObserver(mObserver); + } + + mCursor = cursor; + if (mCursor != null) { + // Attempt to associate the new Cursor with the Observer. + if (mObserver != null) { + mCursor.registerDataSetObserver(mObserver); + } + + mDatasetValid = true; + } else { + mDatasetValid = false; + } + + notifyDataSetChanged(); + } + + /** + * Launches the game that was clicked on. + * + * @param view The card representing the game the user wants to play. + */ + @Override + public void onClick(View view) { + // Double-click prevention, using threshold of 1000 ms + if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) { + return; + } + mLastClickTime = SystemClock.elapsedRealtime(); + + GameViewHolder holder = (GameViewHolder) view.getTag(); + + EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title); + } + + public static class SpacesItemDecoration extends DividerItemDecoration { + private int space; + + public SpacesItemDecoration(Drawable divider, int space) { + super(divider); + this.space = space; + } + + @Override + public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + outRect.left = 0; + outRect.right = 0; + outRect.bottom = space; + outRect.top = 0; + } + } + + private boolean isValidGame(String path) { + return Stream.of( + ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix)); + } + + private final class GameDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + + mDatasetValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + + mDatasetValid = false; + notifyDataSetChanged(); + } + } +} 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 new file mode 100644 index 0000000000..3586a9b349 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java @@ -0,0 +1,122 @@ +// 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.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.Objects; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +public final class MiiSelector { + 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; + AlertDialog.Builder builder = + new AlertDialog.Builder(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/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java new file mode 100644 index 0000000000..7be5f6d977 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java @@ -0,0 +1,264 @@ +// 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 android.text.InputFilter; +import android.text.Spanned; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +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; + +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, + } + + 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.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.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)}); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(editText); + + AlertDialog.Builder builder = new AlertDialog.Builder(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 AlertDialog.Builder(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.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/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java new file mode 100644 index 0000000000..701cb07104 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java @@ -0,0 +1,65 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.camera; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.provider.MediaStore; + +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.PicassoUtils; + +import androidx.annotation.Nullable; + +// Used in native code. +public final class StillImageCameraHelper { + public static final int REQUEST_CAMERA_FILE_PICKER = 1; + private static final Object filePickerLock = new Object(); + private static @Nullable + String filePickerPath; + + // Opens file picker for camera. + public static @Nullable + String OpenFilePicker() { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + // At this point, we are assuming that we already have permissions as they are + // needed to launch a game + emulationActivity.runOnUiThread(() -> { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); + emulationActivity.startActivityForResult( + Intent.createChooser(intent, + emulationActivity.getString(R.string.camera_select_image)), + REQUEST_CAMERA_FILE_PICKER); + }); + + synchronized (filePickerLock) { + try { + filePickerLock.wait(); + } catch (InterruptedException ignored) { + } + } + + return filePickerPath; + } + + // Called from EmulationActivity. + public static void OnFilePickerResult(Intent result) { + filePickerPath = result == null ? null : result.getDataString(); + + synchronized (filePickerLock) { + filePickerLock.notifyAll(); + } + } + + // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Nullable + public static Bitmap LoadImageFromFile(String uri, int width, int height) { + return PicassoUtils.LoadBitmapFromFile(uri, width, height); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java new file mode 100644 index 0000000000..0f10f1858e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java @@ -0,0 +1,140 @@ +package org.citra.citra_emu.dialogs; + +import android.content.Context; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AlertDialog} derivative that listens for + * motion events from controllers and joysticks. + */ +public final class MotionAlertDialog extends AlertDialog { + // The selected input preference + private final InputBindingSetting setting; + private final ArrayList mPreviousValues = new ArrayList<>(); + private int mPrevDeviceId = 0; + private boolean mWaitingForEvent = true; + + /** + * Constructor + * + * @param context The current {@link Context}. + * @param setting The Preference to show this dialog for. + */ + public MotionAlertDialog(Context context, InputBindingSetting setting) { + super(context); + + this.setting = setting; + } + + public boolean onKeyEvent(int keyCode, KeyEvent event) { + Log.debug("[MotionAlertDialog] Received key event: " + event.getAction()); + switch (event.getAction()) { + case KeyEvent.ACTION_UP: + setting.onKeyInput(event); + dismiss(); + // Even if we ignore the key, we still consume it. Thus return true regardless. + return true; + + default: + return false; + } + } + + @Override + public boolean onKeyLongPress(int keyCode, @NonNull KeyEvent event) { + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Handle this key if we care about it, otherwise pass it down the framework + return onKeyEvent(event.getKeyCode(), event) || super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) { + // Handle this event if we care about it, otherwise pass it down the framework + return onMotionEvent(event) || super.dispatchGenericMotionEvent(event); + } + + private boolean onMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0) + return false; + if (event.getAction() != MotionEvent.ACTION_MOVE) + return false; + + InputDevice input = event.getDevice(); + + List motionRanges = input.getMotionRanges(); + + if (input.getId() != mPrevDeviceId) { + mPreviousValues.clear(); + } + mPrevDeviceId = input.getId(); + boolean firstEvent = mPreviousValues.isEmpty(); + + int numMovedAxis = 0; + float axisMoveValue = 0.0f; + InputDevice.MotionRange lastMovedRange = null; + char lastMovedDir = '?'; + if (mWaitingForEvent) { + for (int i = 0; i < motionRanges.size(); i++) { + InputDevice.MotionRange range = motionRanges.get(i); + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = origValue;//ControllerMappingHelper.scaleAxis(input, axis, origValue); + if (firstEvent) { + mPreviousValues.add(value); + } else { + float previousValue = mPreviousValues.get(i); + + // Only handle the axes that are not neutral (more than 0.5) + // but ignore any axis that has a constant value (e.g. always 1) + if (Math.abs(value) > 0.5f && value != previousValue) { + // It is common to have multiple axes with the same physical input. For example, + // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE. + // To handle this, we ignore an axis motion that's the exact same as a motion + // we already saw. This way, we ignore axes with two names, but catch the case + // where a joystick is moved in two directions. + // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html + if (value != axisMoveValue) { + axisMoveValue = value; + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = value < 0.0f ? '-' : '+'; + } + } + // Special case for d-pads (axis value jumps between 0 and 1 without any values + // in between). Without this, the user would need to press the d-pad twice + // due to the first press being caught by the "if (firstEvent)" case further up. + else if (Math.abs(value) < 0.25f && Math.abs(previousValue) > 0.75f) { + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = previousValue < 0.0f ? '-' : '+'; + } + } + + mPreviousValues.set(i, value); + } + + // If only one axis moved, that's the winner. + if (numMovedAxis == 1) { + mWaitingForEvent = false; + setting.onMotionInput(input, lastMovedRange, lastMovedDir); + dismiss(); + } + } + return true; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java new file mode 100644 index 0000000000..d6d14cc5f3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java @@ -0,0 +1,138 @@ +// Copyright 2021 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.disk_shader_cache; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +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; + +public class DiskShaderCacheProgress { + + // Equivalent to VideoCore::LoadCallbackStage + public enum LoadCallbackStage { + Prepare, + Decompile, + Build, + Complete, + } + + private static final Object finishLock = new Object(); + private static ProgressDialogFragment fragment; + + public static class ProgressDialogFragment extends DialogFragment { + ProgressBar progressBar; + TextView progressText; + AlertDialog dialog; + + static ProgressDialogFragment newInstance(String title, String message) { + ProgressDialogFragment frag = new ProgressDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + LayoutInflater inflater = LayoutInflater.from(emulationActivity); + View view = inflater.inflate(R.layout.dialog_progress_bar, null); + + progressBar = view.findViewById(R.id.progress_bar); + progressText = view.findViewById(R.id.progress_text); + progressText.setText(""); + + setCancelable(false); + setRetainInstance(true); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity); + builder.setTitle(title); + builder.setMessage(message); + builder.setView(view); + builder.setNegativeButton(android.R.string.cancel, null); + + dialog = builder.create(); + dialog.create(); + + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v) -> emulationActivity.onBackPressed()); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + + return dialog; + } + + private void onUpdateProgress(String msg, int progress, int max) { + Objects.requireNonNull(getActivity()).runOnUiThread(() -> { + progressBar.setProgress(progress); + progressBar.setMax(max); + progressText.setText(String.format("%d/%d", progress, max)); + dialog.setMessage(msg); + }); + } + } + + private static void prepareDialog() { + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + fragment = ProgressDialogFragment.newInstance(emulationActivity.getString(R.string.loading), emulationActivity.getString(R.string.preparing_shaders)); + fragment.show(emulationActivity.getSupportFragmentManager(), "diskShaders"); + }); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + } + + public static void loadProgress(LoadCallbackStage stage, int progress, int max) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present"); + return; + } + + switch (stage) { + case Prepare: + prepareDialog(); + break; + case Decompile: + fragment.onUpdateProgress(emulationActivity.getString(R.string.preparing_shaders), progress, max); + break; + case Build: + fragment.onUpdateProgress(emulationActivity.getString(R.string.building_shaders), progress, max); + break; + case Complete: + // Workaround for when dialog is dismissed when the app is in the background + fragment.dismissAllowingStateLoss(); + break; + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java new file mode 100644 index 0000000000..93b0263640 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.cheats.model; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class Cheat { + @Keep + private final long mPointer; + + private Runnable mEnabledChangedCallback = null; + + @Keep + private Cheat(long pointer) { + mPointer = pointer; + } + + @Override + protected native void finalize(); + + @NonNull + public native String getName(); + + @NonNull + public native String getNotes(); + + @NonNull + public native String getCode(); + + public native boolean getEnabled(); + + public void setEnabled(boolean enabled) { + setEnabledImpl(enabled); + onEnabledChanged(); + } + + private native void setEnabledImpl(boolean enabled); + + public void setEnabledChangedCallback(@Nullable Runnable callback) { + mEnabledChangedCallback = callback; + } + + private void onEnabledChanged() { + if (mEnabledChangedCallback != null) { + mEnabledChangedCallback.run(); + } + } + + /** + * If the code is valid, returns 0. Otherwise, returns the 1-based index + * for the line containing the error. + */ + public static native int isValidGatewayCode(@NonNull String code); + + public static native Cheat createGatewayCode(@NonNull String name, @NonNull String notes, + @NonNull String code); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java new file mode 100644 index 0000000000..5748162bb1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java @@ -0,0 +1,13 @@ +package org.citra.citra_emu.features.cheats.model; + +public class CheatEngine { + public static native Cheat[] getCheats(); + + public static native void addCheat(Cheat cheat); + + public static native void removeCheat(int index); + + public static native void updateCheat(int index, Cheat newCheat); + + public static native void saveCheatFile(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java new file mode 100644 index 0000000000..66f4202d83 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java @@ -0,0 +1,177 @@ +package org.citra.citra_emu.features.cheats.model; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class CheatsViewModel extends ViewModel { + private int mSelectedCheatPosition = -1; + private final MutableLiveData mSelectedCheat = new MutableLiveData<>(null); + private final MutableLiveData mIsAdding = new MutableLiveData<>(false); + private final MutableLiveData mIsEditing = new MutableLiveData<>(false); + + private final MutableLiveData mCheatAddedEvent = new MutableLiveData<>(null); + private final MutableLiveData mCheatChangedEvent = new MutableLiveData<>(null); + private final MutableLiveData mCheatDeletedEvent = new MutableLiveData<>(null); + private final MutableLiveData mOpenDetailsViewEvent = new MutableLiveData<>(false); + + private Cheat[] mCheats; + private boolean mCheatsNeedSaving = false; + + public void load() { + mCheats = CheatEngine.getCheats(); + + for (int i = 0; i < mCheats.length; i++) { + int position = i; + mCheats[i].setEnabledChangedCallback(() -> { + mCheatsNeedSaving = true; + notifyCheatUpdated(position); + }); + } + } + + public void saveIfNeeded() { + if (mCheatsNeedSaving) { + CheatEngine.saveCheatFile(); + mCheatsNeedSaving = false; + } + } + + public Cheat[] getCheats() { + return mCheats; + } + + public LiveData getSelectedCheat() { + return mSelectedCheat; + } + + public void setSelectedCheat(Cheat cheat, int position) { + if (mIsEditing.getValue()) { + setIsEditing(false); + } + + mSelectedCheat.setValue(cheat); + mSelectedCheatPosition = position; + } + + public LiveData getIsAdding() { + return mIsAdding; + } + + public LiveData getIsEditing() { + return mIsEditing; + } + + public void setIsEditing(boolean isEditing) { + mIsEditing.setValue(isEditing); + + if (mIsAdding.getValue() && !isEditing) { + mIsAdding.setValue(false); + setSelectedCheat(null, -1); + } + } + + /** + * When a cheat is added, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatAddedEvent() { + return mCheatAddedEvent; + } + + private void notifyCheatAdded(int position) { + mCheatAddedEvent.setValue(position); + mCheatAddedEvent.setValue(null); + } + + public void startAddingCheat() { + mSelectedCheat.setValue(null); + mSelectedCheatPosition = -1; + + mIsAdding.setValue(true); + mIsEditing.setValue(true); + } + + public void finishAddingCheat(Cheat cheat) { + if (!mIsAdding.getValue()) { + throw new IllegalStateException(); + } + + mIsAdding.setValue(false); + mIsEditing.setValue(false); + + int position = mCheats.length; + + CheatEngine.addCheat(cheat); + + mCheatsNeedSaving = true; + load(); + + notifyCheatAdded(position); + setSelectedCheat(mCheats[position], position); + } + + /** + * When a cheat is edited, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatUpdatedEvent() { + return mCheatChangedEvent; + } + + /** + * Notifies that an edit has been made to the contents of the cheat at the given position. + */ + private void notifyCheatUpdated(int position) { + mCheatChangedEvent.setValue(position); + mCheatChangedEvent.setValue(null); + } + + public void updateSelectedCheat(Cheat newCheat) { + CheatEngine.updateCheat(mSelectedCheatPosition, newCheat); + + mCheatsNeedSaving = true; + load(); + + notifyCheatUpdated(mSelectedCheatPosition); + setSelectedCheat(mCheats[mSelectedCheatPosition], mSelectedCheatPosition); + } + + /** + * When a cheat is deleted, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatDeletedEvent() { + return mCheatDeletedEvent; + } + + /** + * Notifies that the cheat at the given position has been deleted. + */ + private void notifyCheatDeleted(int position) { + mCheatDeletedEvent.setValue(position); + mCheatDeletedEvent.setValue(null); + } + + public void deleteSelectedCheat() { + int position = mSelectedCheatPosition; + + setSelectedCheat(null, -1); + + CheatEngine.removeCheat(position); + + mCheatsNeedSaving = true; + load(); + + notifyCheatDeleted(position); + } + + public LiveData getOpenDetailsViewEvent() { + return mOpenDetailsViewEvent; + } + + public void openDetailsView() { + mOpenDetailsViewEvent.setValue(true); + mOpenDetailsViewEvent.setValue(false); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java new file mode 100644 index 0000000000..762cdb80e6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java @@ -0,0 +1,174 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatDetailsFragment extends Fragment { + private View mRoot; + private ScrollView mScrollView; + private TextView mLabelName; + private EditText mEditName; + private EditText mEditNotes; + private EditText mEditCode; + private Button mButtonDelete; + private Button mButtonEdit; + private Button mButtonCancel; + private Button mButtonOk; + + private CheatsViewModel mViewModel; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_details, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mRoot = view.findViewById(R.id.root); + mScrollView = view.findViewById(R.id.scroll_view); + mLabelName = view.findViewById(R.id.label_name); + mEditName = view.findViewById(R.id.edit_name); + mEditNotes = view.findViewById(R.id.edit_notes); + mEditCode = view.findViewById(R.id.edit_code); + mButtonDelete = view.findViewById(R.id.button_delete); + mButtonEdit = view.findViewById(R.id.button_edit); + mButtonCancel = view.findViewById(R.id.button_cancel); + mButtonOk = view.findViewById(R.id.button_ok); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + mViewModel.getSelectedCheat().observe(getViewLifecycleOwner(), + this::onSelectedCheatUpdated); + mViewModel.getIsEditing().observe(getViewLifecycleOwner(), this::onIsEditingUpdated); + + mButtonDelete.setOnClickListener(this::onDeleteClicked); + mButtonEdit.setOnClickListener(this::onEditClicked); + mButtonCancel.setOnClickListener(this::onCancelClicked); + mButtonOk.setOnClickListener(this::onOkClicked); + + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> activity.onDetailsViewFocusChange(hasFocus)); + } + + private void clearEditErrors() { + mEditName.setError(null); + mEditCode.setError(null); + } + + private void onDeleteClicked(View view) { + String name = mEditName.getText().toString(); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setMessage(getString(R.string.cheats_delete_confirmation, name)); + builder.setPositiveButton(android.R.string.yes, + (dialog, i) -> mViewModel.deleteSelectedCheat()); + builder.setNegativeButton(android.R.string.no, null); + builder.show(); + } + + private void onEditClicked(View view) { + mViewModel.setIsEditing(true); + mButtonOk.requestFocus(); + } + + private void onCancelClicked(View view) { + mViewModel.setIsEditing(false); + onSelectedCheatUpdated(mViewModel.getSelectedCheat().getValue()); + mButtonDelete.requestFocus(); + } + + private void onOkClicked(View view) { + clearEditErrors(); + + String name = mEditName.getText().toString(); + String notes = mEditNotes.getText().toString(); + String code = mEditCode.getText().toString(); + + if (name.isEmpty()) { + mEditName.setError(getString(R.string.cheats_error_no_name)); + mScrollView.smoothScrollTo(0, mLabelName.getTop()); + return; + } else if (code.isEmpty()) { + mEditCode.setError(getString(R.string.cheats_error_no_code_lines)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + int validityResult = Cheat.isValidGatewayCode(code); + + if (validityResult != 0) { + mEditCode.setError(getString(R.string.cheats_error_on_line, validityResult)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + Cheat newCheat = Cheat.createGatewayCode(name, notes, code); + + if (mViewModel.getIsAdding().getValue()) { + mViewModel.finishAddingCheat(newCheat); + } else { + mViewModel.updateSelectedCheat(newCheat); + } + + mButtonEdit.requestFocus(); + } + + private void onSelectedCheatUpdated(@Nullable Cheat cheat) { + clearEditErrors(); + + boolean isEditing = mViewModel.getIsEditing().getValue(); + + mRoot.setVisibility(isEditing || cheat != null ? View.VISIBLE : View.GONE); + + // If the fragment was recreated while editing a cheat, it's vital that we + // don't repopulate the fields, otherwise the user's changes will be lost + if (!isEditing) { + if (cheat == null) { + mEditName.setText(""); + mEditNotes.setText(""); + mEditCode.setText(""); + } else { + mEditName.setText(cheat.getName()); + mEditNotes.setText(cheat.getNotes()); + mEditCode.setText(cheat.getCode()); + } + } + } + + private void onIsEditingUpdated(boolean isEditing) { + if (isEditing) { + mRoot.setVisibility(View.VISIBLE); + } + + mEditName.setEnabled(isEditing); + mEditNotes.setEnabled(isEditing); + mEditCode.setEnabled(isEditing); + + mButtonDelete.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonEdit.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonCancel.setVisibility(isEditing ? View.VISIBLE : View.GONE); + mButtonOk.setVisibility(isEditing ? View.VISIBLE : View.GONE); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java new file mode 100644 index 0000000000..6c67a31d4e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java @@ -0,0 +1,46 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.DividerItemDecoration; + +public class CheatListFragment extends Fragment { + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + RecyclerView recyclerView = view.findViewById(R.id.cheat_list); + FloatingActionButton fab = view.findViewById(R.id.fab); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + recyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); + recyclerView.setLayoutManager(new LinearLayoutManager(activity)); + recyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); + + fab.setOnClickListener(v -> { + viewModel.startAddingCheat(); + viewModel.openDetailsView(); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java new file mode 100644 index 0000000000..8ba8f86e79 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java @@ -0,0 +1,56 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener, CompoundButton.OnCheckedChangeListener { + private final View mRoot; + private final TextView mName; + private final CheckBox mCheckbox; + + private CheatsViewModel mViewModel; + private Cheat mCheat; + private int mPosition; + + public CheatViewHolder(@NonNull View itemView) { + super(itemView); + + mRoot = itemView.findViewById(R.id.root); + mName = itemView.findViewById(R.id.text_name); + mCheckbox = itemView.findViewById(R.id.checkbox); + } + + public void bind(CheatsActivity activity, Cheat cheat, int position) { + mCheckbox.setOnCheckedChangeListener(null); + + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + mCheat = cheat; + mPosition = position; + + mName.setText(mCheat.getName()); + mCheckbox.setChecked(mCheat.getEnabled()); + + mRoot.setOnClickListener(this); + mCheckbox.setOnCheckedChangeListener(this); + } + + public void onClick(View root) { + mViewModel.setSelectedCheat(mCheat, mPosition); + mViewModel.openDetailsView(); + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mCheat.setEnabled(isChecked); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java new file mode 100644 index 0000000000..a36bf427ca --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java @@ -0,0 +1,161 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.slidingpanelayout.widget.SlidingPaneLayout; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.TwoPaneOnBackPressedCallback; + +public class CheatsActivity extends AppCompatActivity + implements SlidingPaneLayout.PanelSlideListener { + private CheatsViewModel mViewModel; + + private SlidingPaneLayout mSlidingPaneLayout; + private View mCheatList; + private View mCheatDetails; + + private View mCheatListLastFocus; + private View mCheatDetailsLastFocus; + + public static void launch(Context context) { + Intent intent = new Intent(context, CheatsActivity.class); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class); + mViewModel.load(); + + setContentView(R.layout.activity_cheats); + + mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout); + mCheatList = findViewById(R.id.cheat_list); + mCheatDetails = findViewById(R.id.cheat_details); + + mCheatListLastFocus = mCheatList; + mCheatDetailsLastFocus = mCheatDetails; + + mSlidingPaneLayout.addPanelSlideListener(this); + + getOnBackPressedDispatcher().addCallback(this, + new TwoPaneOnBackPressedCallback(mSlidingPaneLayout)); + + mViewModel.getSelectedCheat().observe(this, this::onSelectedCheatChanged); + mViewModel.getIsEditing().observe(this, this::onIsEditingChanged); + onSelectedCheatChanged(mViewModel.getSelectedCheat().getValue()); + + mViewModel.getOpenDetailsViewEvent().observe(this, this::openDetailsView); + + // Show "Up" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onStop() { + super.onStop(); + + mViewModel.saveIfNeeded(); + } + + @Override + public void onPanelSlide(@NonNull View panel, float slideOffset) { + } + + @Override + public void onPanelOpened(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatDetailsLastFocus.requestFocus(rtl ? View.FOCUS_LEFT : View.FOCUS_RIGHT); + } + + @Override + public void onPanelClosed(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatListLastFocus.requestFocus(rtl ? View.FOCUS_RIGHT : View.FOCUS_LEFT); + } + + private void onIsEditingChanged(boolean isEditing) { + if (isEditing) { + mSlidingPaneLayout.setLockMode(SlidingPaneLayout.LOCK_MODE_UNLOCKED); + } + } + + private void onSelectedCheatChanged(Cheat selectedCheat) { + boolean cheatSelected = selectedCheat != null || mViewModel.getIsEditing().getValue(); + + if (!cheatSelected && mSlidingPaneLayout.isOpen()) { + mSlidingPaneLayout.close(); + } + + mSlidingPaneLayout.setLockMode(cheatSelected ? + SlidingPaneLayout.LOCK_MODE_UNLOCKED : SlidingPaneLayout.LOCK_MODE_LOCKED_CLOSED); + } + + public void onListViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatListLastFocus = mCheatList.findFocus(); + if (mCheatListLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.close(); + } + } + + public void onDetailsViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatDetailsLastFocus = mCheatDetails.findFocus(); + if (mCheatDetailsLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.open(); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + private void openDetailsView(boolean open) { + if (open) { + mSlidingPaneLayout.open(); + } + } + + public static void setOnFocusChangeListenerRecursively(@NonNull View view, + View.OnFocusChangeListener listener) { + view.setOnFocusChangeListener(listener); + + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + setOnFocusChangeListenerRecursively(child, listener); + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java new file mode 100644 index 0000000000..9cb2ce8d8e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java @@ -0,0 +1,72 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatsAdapter extends RecyclerView.Adapter { + private final CheatsActivity mActivity; + private final CheatsViewModel mViewModel; + + public CheatsAdapter(CheatsActivity activity, CheatsViewModel viewModel) { + mActivity = activity; + mViewModel = viewModel; + + mViewModel.getCheatAddedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemInserted(position); + } + }); + + mViewModel.getCheatUpdatedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemChanged(position); + } + }); + + mViewModel.getCheatDeletedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemRemoved(position); + } + }); + } + + @NonNull + @Override + public CheatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + View cheatView = inflater.inflate(R.layout.list_item_cheat, parent, false); + addViewListeners(cheatView); + return new CheatViewHolder(cheatView); + } + + @Override + public void onBindViewHolder(@NonNull CheatViewHolder holder, int position) { + holder.bind(mActivity, getItemAt(position), position); + } + + @Override + public int getItemCount() { + return mViewModel.getCheats().length; + } + + private void addViewListeners(View view) { + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> mActivity.onListViewFocusChange(hasFocus)); + } + + private Cheat getItemAt(int position) { + return mViewModel.getCheats()[position]; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java new file mode 100644 index 0000000000..932dcf1d32 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class BooleanSetting extends Setting { + private boolean mValue; + + public BooleanSetting(String key, String section, boolean value) { + super(key, section); + mValue = value; + } + + public boolean getValue() { + return mValue; + } + + public void setValue(boolean value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue ? "True" : "False"; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java new file mode 100644 index 0000000000..275f0eceae --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class FloatSetting extends Setting { + private float mValue; + + public FloatSetting(String key, String section, float value) { + super(key, section); + mValue = value; + } + + public float getValue() { + return mValue; + } + + public void setValue(float value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Float.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java new file mode 100644 index 0000000000..f712e5bfa4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class IntSetting extends Setting { + private int mValue; + + public IntSetting(String key, String section, int value) { + super(key, section); + mValue = value; + } + + public int getValue() { + return mValue; + } + + public void setValue(int value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Integer.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java new file mode 100644 index 0000000000..b762847c94 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java @@ -0,0 +1,42 @@ +package org.citra.citra_emu.features.settings.model; + +/** + * Abstraction for a setting item as read from / written to Citra's configuration ini files. + * These files generally consist of a key/value pair, though the type of value is ambiguous and + * must be inferred at read-time. The type of value determines which child of this class is used + * to represent the Setting. + */ +public abstract class Setting { + private String mKey; + private String mSection; + + /** + * Base constructor. + * + * @param key Everything to the left of the = in a line from the ini file. + * @param section The corresponding recent section header; e.g. [Core] or [Enhancements] without the brackets. + */ + public Setting(String key, String section) { + mKey = key; + mSection = section; + } + + /** + * @return The identifier used to write this setting to the ini file. + */ + public String getKey() { + return mKey; + } + + /** + * @return The name of the header under which this Setting should be written in the ini file. + */ + public String getSection() { + return mSection; + } + + /** + * @return A representation of this Setting's backing value converted to a String (e.g. for serialization). + */ + public abstract String getValueAsString(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java new file mode 100644 index 0000000000..0a291aa6bb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.model; + +import java.util.HashMap; + +/** + * A semantically-related group of Settings objects. These Settings are + * internally stored as a HashMap. + */ +public final class SettingSection { + private String mName; + + private HashMap mSettings = new HashMap<>(); + + /** + * Create a new SettingSection with no Settings in it. + * + * @param name The header of this section; e.g. [Core] or [Enhancements] without the brackets. + */ + public SettingSection(String name) { + mName = name; + } + + public String getName() { + return mName; + } + + /** + * Convenience method; inserts a value directly into the backing HashMap. + * + * @param setting The Setting to be inserted. + */ + public void putSetting(Setting setting) { + mSettings.put(setting.getKey(), setting); + } + + /** + * Convenience method; gets a value directly from the backing HashMap. + * + * @param key Used to retrieve the Setting. + * @return A Setting object (you should probably cast this before using) + */ + public Setting getSetting(String key) { + return mSettings.get(key); + } + + public HashMap getSettings() { + return mSettings; + } + + public void mergeSection(SettingSection settingSection) { + for (Setting setting : settingSection.mSettings.values()) { + putSetting(setting); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java new file mode 100644 index 0000000000..9684966f20 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java @@ -0,0 +1,132 @@ +package org.citra.citra_emu.features.settings.model; + +import android.text.TextUtils; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public class Settings { + public static final String SECTION_PREMIUM = "Premium"; + public static final String SECTION_CORE = "Core"; + public static final String SECTION_SYSTEM = "System"; + public static final String SECTION_CAMERA = "Camera"; + public static final String SECTION_CONTROLS = "Controls"; + public static final String SECTION_RENDERER = "Renderer"; + public static final String SECTION_LAYOUT = "Layout"; + public static final String SECTION_UTILITY = "Utility"; + public static final String SECTION_AUDIO = "Audio"; + public static final String SECTION_DEBUG = "Debug"; + + private String gameId; + + private static final Map> configFileSectionsMap = new HashMap<>(); + + static { + configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG)); + } + + /** + * A HashMap that constructs a new SettingSection instead of returning null + * when getting a key not already in the map + */ + public static final class SettingsSectionMap extends HashMap { + @Override + public SettingSection get(Object key) { + if (!(key instanceof String)) { + return null; + } + + String stringKey = (String) key; + + if (!super.containsKey(stringKey)) { + SettingSection section = new SettingSection(stringKey); + super.put(stringKey, section); + return section; + } + return super.get(key); + } + } + + private HashMap sections = new Settings.SettingsSectionMap(); + + public SettingSection getSection(String sectionName) { + return sections.get(sectionName); + } + + public boolean isEmpty() { + return sections.isEmpty(); + } + + public HashMap getSections() { + return sections; + } + + public void loadSettings(SettingsActivityView view) { + sections = new Settings.SettingsSectionMap(); + loadCitraSettings(view); + + if (!TextUtils.isEmpty(gameId)) { + loadCustomGameSettings(gameId, view); + } + } + + private void loadCitraSettings(SettingsActivityView view) { + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + sections.putAll(SettingsFile.readFile(fileName, view)); + } + } + + private void loadCustomGameSettings(String gameId, SettingsActivityView view) { + // custom game settings + mergeSections(SettingsFile.readCustomGameSettings(gameId, view)); + } + + private void mergeSections(HashMap updatedSections) { + for (Map.Entry entry : updatedSections.entrySet()) { + if (sections.containsKey(entry.getKey())) { + SettingSection originalSection = sections.get(entry.getKey()); + SettingSection updatedSection = entry.getValue(); + originalSection.mergeSection(updatedSection); + } else { + sections.put(entry.getKey(), entry.getValue()); + } + } + } + + public void loadSettings(String gameId, SettingsActivityView view) { + this.gameId = gameId; + loadSettings(view); + } + + public void saveSettings(SettingsActivityView view) { + if (TextUtils.isEmpty(gameId)) { + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false); + + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + List sectionNames = entry.getValue(); + TreeMap iniSections = new TreeMap<>(); + for (String section : sectionNames) { + iniSections.put(section, sections.get(section)); + } + + SettingsFile.saveFile(fileName, iniSections, view); + } + } else { + // custom game settings + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false); + + SettingsFile.saveCustomGameSettings(gameId, sections); + } + + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java new file mode 100644 index 0000000000..b906b70109 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class StringSetting extends Setting { + private String mValue; + + public StringSetting(String key, String section, String value) { + super(key, section); + mValue = value; + } + + public String getValue() { + return mValue; + } + + public void setValue(String value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java new file mode 100644 index 0000000000..baf40709fe --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java @@ -0,0 +1,80 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.BooleanSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class CheckBoxSetting extends SettingsItem { + private boolean mDefaultValue; + private boolean mShowPerformanceWarning; + private SettingsFragmentView mView; + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mShowPerformanceWarning = false; + } + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting, boolean show_performance_warning, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mView = view; + mShowPerformanceWarning = show_performance_warning; + } + + public boolean isChecked() { + if (getSetting() == null) { + return mDefaultValue; + } + + // Try integer setting + try { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue() == 1; + } catch (ClassCastException exception) { + } + + // Try boolean setting + try { + BooleanSetting setting = (BooleanSetting) getSetting(); + return setting.getValue() == true; + } catch (ClassCastException exception) { + } + + return mDefaultValue; + } + + /** + * Write a value to the backing boolean. If that boolean was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param checked Pretty self explanatory. + * @return null if overwritten successfully; otherwise, a newly created BooleanSetting. + */ + public IntSetting setChecked(boolean checked) { + // Show a performance warning if the setting has been disabled + if (mShowPerformanceWarning && !checked) { + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true); + } + + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), checked ? 1 : 0); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(checked ? 1 : 0); + return null; + } + } + + @Override + public int getType() { + return TYPE_CHECKBOX; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java new file mode 100644 index 0000000000..afc3352cc0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java @@ -0,0 +1,40 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public final class DateTimeSetting extends SettingsItem { + private String mDefaultValue; + + public DateTimeSetting(String key, String section, int titleId, int descriptionId, + String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + } + + public String getValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public StringSetting setSelectedValue(String datetime) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), datetime); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(datetime); + return null; + } + } + + @Override + public int getType() { + return TYPE_DATETIME_SETTING; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java new file mode 100644 index 0000000000..bac8876cdf --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java @@ -0,0 +1,14 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class HeaderSetting extends SettingsItem { + public HeaderSetting(String key, Setting setting, int titleId, int descriptionId) { + super(key, null, setting, titleId, descriptionId); + } + + @Override + public int getType() { + return SettingsItem.TYPE_HEADER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java new file mode 100644 index 0000000000..e9141a2080 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java @@ -0,0 +1,382 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.widget.Toast; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +public final class InputBindingSetting extends SettingsItem { + private static final String INPUT_MAPPING_PREFIX = "InputMapping"; + + public InputBindingSetting(String key, String section, int titleId, Setting setting) { + super(key, section, setting, titleId, 0); + } + + public String getValue() { + if (getSetting() == null) { + return ""; + } + + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } + + /** + * Returns true if this key is for the 3DS Circle Pad + */ + private boolean IsCirclePad() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad + */ + public boolean IsHorizontalOrientation() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS C-Stick + */ + private boolean IsCStick() { + switch (getKey()) { + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS D-Pad + */ + private boolean IsDPad() { + switch (getKey()) { + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real + * triggers on the 3DS, but we support them as such on a physical gamepad. + */ + public boolean IsTrigger() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_L: + case SettingsFile.KEY_BUTTON_R: + case SettingsFile.KEY_BUTTON_ZL: + case SettingsFile.KEY_BUTTON_ZR: + return true; + } + return false; + } + + /** + * Returns true if a gamepad axis can be used to map this key. + */ + public boolean IsAxisMappingSupported() { + return IsCirclePad() || IsCStick() || IsDPad() || IsTrigger(); + } + + /** + * Returns true if a gamepad button can be used to map this key. + */ + private boolean IsButtonMappingSupported() { + return !IsAxisMappingSupported() || IsTrigger(); + } + + /** + * Returns the Citra button code for the settings key. + */ + private int getButtonCode() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_A: + return NativeLibrary.ButtonType.BUTTON_A; + case SettingsFile.KEY_BUTTON_B: + return NativeLibrary.ButtonType.BUTTON_B; + case SettingsFile.KEY_BUTTON_X: + return NativeLibrary.ButtonType.BUTTON_X; + case SettingsFile.KEY_BUTTON_Y: + return NativeLibrary.ButtonType.BUTTON_Y; + case SettingsFile.KEY_BUTTON_L: + return NativeLibrary.ButtonType.TRIGGER_L; + case SettingsFile.KEY_BUTTON_R: + return NativeLibrary.ButtonType.TRIGGER_R; + case SettingsFile.KEY_BUTTON_ZL: + return NativeLibrary.ButtonType.BUTTON_ZL; + case SettingsFile.KEY_BUTTON_ZR: + return NativeLibrary.ButtonType.BUTTON_ZR; + case SettingsFile.KEY_BUTTON_SELECT: + return NativeLibrary.ButtonType.BUTTON_SELECT; + case SettingsFile.KEY_BUTTON_START: + return NativeLibrary.ButtonType.BUTTON_START; + case SettingsFile.KEY_BUTTON_UP: + return NativeLibrary.ButtonType.DPAD_UP; + case SettingsFile.KEY_BUTTON_DOWN: + return NativeLibrary.ButtonType.DPAD_DOWN; + case SettingsFile.KEY_BUTTON_LEFT: + return NativeLibrary.ButtonType.DPAD_LEFT; + case SettingsFile.KEY_BUTTON_RIGHT: + return NativeLibrary.ButtonType.DPAD_RIGHT; + } + return -1; + } + + /** + * Returns the settings key for the specified Citra button code. + */ + private static String getButtonKey(int buttonCode) { + switch (buttonCode) { + case NativeLibrary.ButtonType.BUTTON_A: + return SettingsFile.KEY_BUTTON_A; + case NativeLibrary.ButtonType.BUTTON_B: + return SettingsFile.KEY_BUTTON_B; + case NativeLibrary.ButtonType.BUTTON_X: + return SettingsFile.KEY_BUTTON_X; + case NativeLibrary.ButtonType.BUTTON_Y: + return SettingsFile.KEY_BUTTON_Y; + case NativeLibrary.ButtonType.TRIGGER_L: + return SettingsFile.KEY_BUTTON_L; + case NativeLibrary.ButtonType.TRIGGER_R: + return SettingsFile.KEY_BUTTON_R; + case NativeLibrary.ButtonType.BUTTON_ZL: + return SettingsFile.KEY_BUTTON_ZL; + case NativeLibrary.ButtonType.BUTTON_ZR: + return SettingsFile.KEY_BUTTON_ZR; + case NativeLibrary.ButtonType.BUTTON_SELECT: + return SettingsFile.KEY_BUTTON_SELECT; + case NativeLibrary.ButtonType.BUTTON_START: + return SettingsFile.KEY_BUTTON_START; + case NativeLibrary.ButtonType.DPAD_UP: + return SettingsFile.KEY_BUTTON_UP; + case NativeLibrary.ButtonType.DPAD_DOWN: + return SettingsFile.KEY_BUTTON_DOWN; + case NativeLibrary.ButtonType.DPAD_LEFT: + return SettingsFile.KEY_BUTTON_LEFT; + case NativeLibrary.ButtonType.DPAD_RIGHT: + return SettingsFile.KEY_BUTTON_RIGHT; + } + return ""; + } + + /** + * Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old + * settings on re-mapping or clearing of a setting. + */ + private String getReverseKey() { + String reverseKey = INPUT_MAPPING_PREFIX + "_ReverseMapping_" + getKey(); + + if (IsAxisMappingSupported() && !IsTrigger()) { + // Triggers are the only axis-supported mappings without orientation + reverseKey += "_" + (IsHorizontalOrientation() ? 0 : 1); + } + + return reverseKey; + } + + /** + * Removes the old mapping for this key from the settings, e.g. on user clearing the setting. + */ + public void removeOldMapping() { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Try remove all possible keys we wrote for this setting + String oldKey = preferences.getString(getReverseKey(), ""); + if (!oldKey.equals("")) { + editor.remove(getKey()); // Used for ui text + editor.remove(oldKey); // Used for button mapping + editor.remove(oldKey + "_GuestOrientation"); // Used for axis orientation + editor.remove(oldKey + "_GuestButton"); // Used for axis button + } + + // Apply changes + editor.apply(); + } + + /** + * Helper function to get the settings key for an gamepad button. + */ + public static String getInputButtonKey(int keyCode) { + return INPUT_MAPPING_PREFIX + "_Button_" + keyCode; + } + + /** + * Helper function to get the settings key for an gamepad axis. + */ + public static String getInputAxisKey(int axis) { + return INPUT_MAPPING_PREFIX + "_HostAxis_" + axis; + } + + /** + * Helper function to get the settings key for an gamepad axis button (stick or trigger). + */ + public static String getInputAxisButtonKey(int axis) { + return getInputAxisKey(axis) + "_GuestButton"; + } + + /** + * Helper function to get the settings key for an gamepad axis orientation. + */ + public static String getInputAxisOrientationKey(int axis) { + return getInputAxisKey(axis) + "_GuestOrientation"; + } + + /** + * Helper function to write a gamepad button mapping for the setting. + */ + private void WriteButtonMapping(String key) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Remove mapping for another setting using this input + int oldButtonCode = preferences.getInt(key, -1); + if (oldButtonCode != -1) { + String oldKey = getButtonKey(oldButtonCode); + editor.remove(oldKey); // Only need to remove UI text setting, others will be overwritten + } + + // Cleanup old mapping for this setting + removeOldMapping(); + + // Write new mapping + editor.putInt(key, getButtonCode()); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), key); + + // Apply changes + editor.apply(); + } + + /** + * Helper function to write a gamepad axis mapping for the setting. + */ + private void WriteAxisMapping(int axis, int value) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Cleanup old mapping + removeOldMapping(); + + // Write new mapping + editor.putInt(getInputAxisOrientationKey(axis), IsHorizontalOrientation() ? 0 : 1); + editor.putInt(getInputAxisButtonKey(axis), value); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), getInputAxisKey(axis)); + + // Apply changes + editor.apply(); + } + + /** + * Saves the provided key input setting as an Android preference. + * + * @param keyEvent KeyEvent of this key press. + */ + public void onKeyInput(KeyEvent keyEvent) { + if (!IsButtonMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show(); + return; + } + + InputDevice device = keyEvent.getDevice(); + + WriteButtonMapping(getInputButtonKey(keyEvent.getKeyCode())); + + String uiString = device.getName() + ": Button " + keyEvent.getKeyCode(); + setUiString(uiString); + } + + /** + * Saves the provided motion input setting as an Android preference. + * + * @param device InputDevice from which the input event originated. + * @param motionRange MotionRange of the movement + * @param axisDir Either '-' or '+' (currently unused) + */ + public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange, + char axisDir) { + if (!IsAxisMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show(); + return; + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + int button; + if (IsCirclePad()) { + button = NativeLibrary.ButtonType.STICK_LEFT; + } else if (IsCStick()) { + button = NativeLibrary.ButtonType.STICK_C; + } else if (IsDPad()) { + button = NativeLibrary.ButtonType.DPAD; + } else { + button = getButtonCode(); + } + + WriteAxisMapping(motionRange.getAxis(), button); + + String uiString = device.getName() + ": Axis " + motionRange.getAxis(); + setUiString(uiString); + + editor.apply(); + } + + /** + * Sets the string to use in the configuration UI for the gamepad input. + */ + private StringSetting setUiString(String ui) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), ""); + setSetting(setting); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return null; + } + } + + @Override + public int getType() { + return TYPE_INPUT_BINDING; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java new file mode 100644 index 0000000000..2b1793d3e0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java @@ -0,0 +1,12 @@ +package org.citra.citra_emu.features.settings.model.view; + +public final class PremiumHeader extends SettingsItem { + public PremiumHeader() { + super(null, null, null, 0, 0); + } + + @Override + public int getType() { + return SettingsItem.TYPE_PREMIUM; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java new file mode 100644 index 0000000000..c0560d2dc6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java @@ -0,0 +1,59 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class PremiumSingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + private SettingsFragmentView mView; + + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + mView = view; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + return mPreferences.getInt(getKey(), mDefaultValue); + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public void setSelectedValue(int selection) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt(getKey(), selection); + editor.apply(); + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false); + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java new file mode 100644 index 0000000000..3053520223 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java @@ -0,0 +1,107 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a {@link Setting} object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +public abstract class SettingsItem { + public static final int TYPE_HEADER = 0; + public static final int TYPE_CHECKBOX = 1; + public static final int TYPE_SINGLE_CHOICE = 2; + public static final int TYPE_SLIDER = 3; + public static final int TYPE_SUBMENU = 4; + public static final int TYPE_INPUT_BINDING = 5; + public static final int TYPE_STRING_SINGLE_CHOICE = 6; + public static final int TYPE_DATETIME_SETTING = 7; + public static final int TYPE_PREMIUM = 8; + + private String mKey; + private String mSection; + + private Setting mSetting; + + private int mNameId; + private int mDescriptionId; + private boolean mIsPremium; + + /** + * Base constructor. Takes a key / section name in case the third parameter, the Setting, + * is null; in which case, one can be constructed and saved using the key / section. + * + * @param key Identifier for the Setting represented by this Item. + * @param section Section to which the Setting belongs. + * @param setting A possibly-null backing Setting, to be modified on UI events. + * @param nameId Resource ID for a text string to be displayed as this setting's name. + * @param descriptionId Resource ID for a text string to be displayed as this setting's description. + */ + public SettingsItem(String key, String section, Setting setting, int nameId, + int descriptionId) { + mKey = key; + mSection = section; + mSetting = setting; + mNameId = nameId; + mDescriptionId = descriptionId; + mIsPremium = (section == Settings.SECTION_PREMIUM); + } + + /** + * @return The identifier for the backing Setting. + */ + public String getKey() { + return mKey; + } + + /** + * @return The header under which the backing Setting belongs. + */ + public String getSection() { + return mSection; + } + + /** + * @return The backing Setting, possibly null. + */ + public Setting getSetting() { + return mSetting; + } + + /** + * Replace the backing setting with a new one. Generally used in cases where + * the backing setting is null. + * + * @param setting A non-null Setting. + */ + public void setSetting(Setting setting) { + mSetting = setting; + } + + /** + * @return A resource ID for a text string representing this Setting's name. + */ + public int getNameId() { + return mNameId; + } + + public int getDescriptionId() { + return mDescriptionId; + } + + public boolean isPremium() { + return mIsPremium; + } + + /** + * Used by {@link SettingsAdapter}'s onCreateViewHolder() + * method to determine which type of ViewHolder should be created. + * + * @return An integer (ideally, one of the constants defined in this file) + */ + public abstract int getType(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java new file mode 100644 index 0000000000..ee9d225d60 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java @@ -0,0 +1,60 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + + public SingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + if (getSetting() != null) { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java new file mode 100644 index 0000000000..551b13f999 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java @@ -0,0 +1,101 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.utils.Log; + +public final class SliderSetting extends SettingsItem { + private int mMin; + private int mMax; + private int mDefaultValue; + + private String mUnits; + + public SliderSetting(String key, String section, int titleId, int descriptionId, + int min, int max, String units, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mMin = min; + mMax = max; + mUnits = units; + mDefaultValue = defaultValue; + } + + public int getMin() { + return mMin; + } + + public int getMax() { + return mMax; + } + + public int getDefaultValue() { + return mDefaultValue; + } + + public int getSelectedValue() { + Setting setting = getSetting(); + + if (setting == null) { + return mDefaultValue; + } + + if (setting instanceof IntSetting) { + IntSetting intSetting = (IntSetting) setting; + return intSetting.getValue(); + } else if (setting instanceof FloatSetting) { + FloatSetting floatSetting = (FloatSetting) setting; + return Math.round(floatSetting.getValue()); + } else { + Log.error("[SliderSetting] Error casting setting type."); + return -1; + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + /** + * Write a value to the backing float. If that float was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the float. + * @return null if overwritten successfully otherwise; a newly created FloatSetting. + */ + public FloatSetting setSelectedValue(float selection) { + if (getSetting() == null) { + FloatSetting setting = new FloatSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + FloatSetting setting = (FloatSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + public String getUnits() { + return mUnits; + } + + @Override + public int getType() { + return TYPE_SLIDER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java new file mode 100644 index 0000000000..057145d9da --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java @@ -0,0 +1,82 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public class StringSingleChoiceSetting extends SettingsItem { + private String mDefaultValue; + + private String[] mChoicesId; + private String[] mValuesId; + + public StringSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + String[] choicesId, String[] valuesId, String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public String[] getChoicesId() { + return mChoicesId; + } + + public String[] getValuesId() { + return mValuesId; + } + + public String getValueAt(int index) { + if (mValuesId == null) + return null; + + if (index >= 0 && index < mValuesId.length) { + return mValuesId[index]; + } + + return ""; + } + + public String getSelectedValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public int getSelectValueIndex() { + String selectedValue = getSelectedValue(); + for (int i = 0; i < mValuesId.length; i++) { + if (mValuesId[i].equals(selectedValue)) { + return i; + } + } + + return -1; + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public StringSetting setSelectedValue(String selection) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_STRING_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java new file mode 100644 index 0000000000..9d44a923fd --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java @@ -0,0 +1,21 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SubmenuSetting extends SettingsItem { + private String mMenuKey; + + public SubmenuSetting(String key, Setting setting, int titleId, int descriptionId, String menuKey) { + super(key, null, setting, titleId, descriptionId); + mMenuKey = menuKey; + } + + public String getMenuKey() { + return mMenuKey; + } + + @Override + public int getType() { + return TYPE_SUBMENU; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java new file mode 100644 index 0000000000..23c3c4c9eb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java @@ -0,0 +1,215 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.provider.Settings; +import android.view.Menu; +import android.view.MenuInflater; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentTransaction; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView { + private static final String ARG_MENU_TAG = "menu_tag"; + private static final String ARG_GAME_ID = "game_id"; + private static final String FRAGMENT_TAG = "settings"; + private SettingsActivityPresenter mPresenter = new SettingsActivityPresenter(this); + + private ProgressDialog dialog; + + public static void launch(Context context, String menuTag, String gameId) { + Intent settings = new Intent(context, SettingsActivity.class); + settings.putExtra(ARG_MENU_TAG, menuTag); + settings.putExtra(ARG_GAME_ID, gameId); + context.startActivity(settings); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_settings); + + Intent launcher = getIntent(); + String gameID = launcher.getStringExtra(ARG_GAME_ID); + String menuTag = launcher.getStringExtra(ARG_MENU_TAG); + + mPresenter.onCreate(savedInstanceState, menuTag, gameID); + + // Show "Back" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + // Critical: If super method is not called, rotations will be busted. + super.onSaveInstanceState(outState); + mPresenter.saveState(outState); + } + + @Override + protected void onStart() { + super.onStart(); + mPresenter.onStart(); + } + + /** + * If this is called, the user has left the settings screen (potentially through the + * home button) and will expect their changes to be persisted. So we kick off an + * IntentService which will do so on a background thread. + */ + @Override + protected void onStop() { + super.onStop(); + + mPresenter.onStop(isFinishing()); + + // Update framebuffer layout when closing the settings + NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), + getWindowManager().getDefaultDisplay().getRotation()); + } + + @Override + public void showSettingsFragment(String menuTag, boolean addToStack, String gameID) { + if (!addToStack && getFragment() != null) { + return; + } + + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + + if (addToStack) { + if (areSystemAnimationsEnabled()) { + transaction.setCustomAnimations( + R.animator.settings_enter, + R.animator.settings_exit, + R.animator.settings_pop_enter, + R.animator.setttings_pop_exit); + } + + transaction.addToBackStack(null); + } + transaction.replace(R.id.frame_content, SettingsFragment.newInstance(menuTag, gameID), FRAGMENT_TAG); + + transaction.commit(); + } + + private boolean areSystemAnimationsEnabled() { + float duration = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1); + float transition = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.TRANSITION_ANIMATION_SCALE, 1); + return duration != 0 && transition != 0; + } + + @Override + public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) { + LocalBroadcastManager.getInstance(this).registerReceiver( + receiver, + filter); + DirectoryInitialization.start(this); + } + + @Override + public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); + } + + @Override + public void showLoading() { + if (dialog == null) { + dialog = new ProgressDialog(this); + dialog.setMessage(getString(R.string.load_settings)); + dialog.setIndeterminate(true); + } + + dialog.show(); + } + + @Override + public void hideLoading() { + dialog.dismiss(); + } + + @Override + public void showPermissionNeededHint() { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public void showExternalStorageNotMountedHint() { + Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public org.citra.citra_emu.features.settings.model.Settings getSettings() { + return mPresenter.getSettings(); + } + + @Override + public void setSettings(org.citra.citra_emu.features.settings.model.Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void onSettingsFileLoaded(org.citra.citra_emu.features.settings.model.Settings settings) { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.onSettingsFileLoaded(settings); + } + } + + @Override + public void onSettingsFileNotFound() { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.loadDefaultSettings(); + } + } + + @Override + public void showToastMessage(String message, boolean is_long) { + Toast.makeText(this, message, is_long ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show(); + } + + @Override + public void onSettingChanged() { + mPresenter.onSettingChanged(); + } + + private SettingsFragment getFragment() { + return (SettingsFragment) getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java new file mode 100644 index 0000000000..0d63873bb5 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java @@ -0,0 +1,124 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; +import android.os.Bundle; +import android.text.TextUtils; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.ThemeUtil; + +import java.io.File; + +public final class SettingsActivityPresenter { + private static final String KEY_SHOULD_SAVE = "should_save"; + + private SettingsActivityView mView; + + private Settings mSettings = new Settings(); + + private boolean mShouldSave; + + private DirectoryStateReceiver directoryStateReceiver; + + private String menuTag; + private String gameId; + + public SettingsActivityPresenter(SettingsActivityView view) { + mView = view; + } + + public void onCreate(Bundle savedInstanceState, String menuTag, String gameId) { + if (savedInstanceState == null) { + this.menuTag = menuTag; + this.gameId = gameId; + } else { + mShouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE); + } + } + + public void onStart() { + prepareCitraDirectoriesIfNeeded(); + } + + void loadSettingsUI() { + if (mSettings.isEmpty()) { + if (!TextUtils.isEmpty(gameId)) { + mSettings.loadSettings(gameId, mView); + } else { + mSettings.loadSettings(mView); + } + } + + mView.showSettingsFragment(menuTag, false, gameId); + mView.onSettingsFileLoaded(mSettings); + } + + private void prepareCitraDirectoriesIfNeeded() { + File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini"); + if (!configFile.exists()) { + Log.error("Citra config file could not be found!"); + } + if (DirectoryInitialization.areCitraDirectoriesReady()) { + loadSettingsUI(); + } else { + mView.showLoading(); + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mView.hideLoading(); + loadSettingsUI(); + } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + mView.showPermissionNeededHint(); + mView.hideLoading(); + } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + mView.showExternalStorageNotMountedHint(); + mView.hideLoading(); + } + }); + + mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter); + } + } + + public void setSettings(Settings settings) { + mSettings = settings; + } + + public Settings getSettings() { + return mSettings; + } + + public void onStop(boolean finishing) { + if (directoryStateReceiver != null) { + mView.stopListeningToDirectoryInitializationService(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mSettings != null && finishing && mShouldSave) { + Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI..."); + mSettings.saveSettings(mView); + } + + ThemeUtil.applyTheme(); + + NativeLibrary.ReloadSettings(); + } + + public void onSettingChanged() { + mShouldSave = true; + } + + public void saveState(Bundle outState) { + outState.putBoolean(KEY_SHOULD_SAVE, mShouldSave); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java new file mode 100644 index 0000000000..0d26d48a71 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java @@ -0,0 +1,103 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; + +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.utils.DirectoryStateReceiver; + +/** + * Abstraction for the Activity that manages SettingsFragments. + */ +public interface SettingsActivityView { + /** + * Show a new SettingsFragment. + * + * @param menuTag Identifier for the settings group that should be displayed. + * @param addToStack Whether or not this fragment should replace a previous one. + */ + void showSettingsFragment(String menuTag, boolean addToStack, String gameId); + + /** + * Called by a contained Fragment to get access to the Setting HashMap + * loaded from disk, so that each Fragment doesn't need to perform its own + * read operation. + * + * @return A possibly null HashMap of Settings. + */ + Settings getSettings(); + + /** + * Used to provide the Activity with Settings HashMaps if a Fragment already + * has one; for example, if a rotation occurs, the Fragment will not be killed, + * but the Activity will, so the Activity needs to have its HashMaps resupplied. + * + * @param settings The ArrayList of all the Settings HashMaps. + */ + void setSettings(Settings settings); + + /** + * Called when an asynchronous load operation completes. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Called when an asynchronous load operation fails. + */ + void onSettingsFileNotFound(); + + /** + * Display a popup text message on screen. + * + * @param message The contents of the onscreen message. + * @param is_long Whether this should be a long Toast or short one. + */ + void showToastMessage(String message, boolean is_long); + + /** + * End the activity. + */ + void finish(); + + /** + * Called by a containing Fragment to tell the Activity that a setting was changed; + * unless this has been called, the Activity will not save to disk. + */ + void onSettingChanged(); + + /** + * Show loading dialog while loading the settings + */ + void showLoading(); + + /** + * Hide the loading the dialog + */ + void hideLoading(); + + /** + * Show a hint to the user that the app needs write to external storage access + */ + void showPermissionNeededHint(); + + /** + * Show a hint to the user that the app needs the external storage to be mounted + */ + void showExternalStorageNotMountedHint(); + + /** + * Start the DirectoryInitialization and listen for the result. + * + * @param receiver the broadcast receiver for the DirectoryInitialization + * @param filter the Intent broadcasts to be received. + */ + void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter); + + /** + * Stop listening to the DirectoryInitialization. + * + * @param receiver The broadcast receiver to unregister. + */ + void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java new file mode 100644 index 0000000000..bfd7c71a99 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java @@ -0,0 +1,487 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.DatePicker; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.TimePicker; + +import androidx.appcompat.app.AlertDialog; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.dialogs.MotionAlertDialog; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; + +public final class SettingsAdapter extends RecyclerView.Adapter + implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener { + private SettingsFragmentView mView; + private Context mContext; + private ArrayList mSettings; + + private SettingsItem mClickedItem; + private int mClickedPosition; + private int mSeekbarProgress; + + private AlertDialog mDialog; + private TextView mTextSliderValue; + + public SettingsAdapter(SettingsFragmentView view, Context context) { + mView = view; + mContext = context; + mClickedPosition = -1; + } + + @Override + public SettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view; + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + switch (viewType) { + case SettingsItem.TYPE_HEADER: + view = inflater.inflate(R.layout.list_item_settings_header, parent, false); + return new HeaderViewHolder(view, this); + + case SettingsItem.TYPE_CHECKBOX: + view = inflater.inflate(R.layout.list_item_setting_checkbox, parent, false); + return new CheckBoxSettingViewHolder(view, this); + + case SettingsItem.TYPE_SINGLE_CHOICE: + case SettingsItem.TYPE_STRING_SINGLE_CHOICE: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SingleChoiceViewHolder(view, this); + + case SettingsItem.TYPE_SLIDER: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SliderViewHolder(view, this); + + case SettingsItem.TYPE_SUBMENU: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SubmenuViewHolder(view, this); + + case SettingsItem.TYPE_INPUT_BINDING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new InputBindingSettingViewHolder(view, this, mContext); + + case SettingsItem.TYPE_DATETIME_SETTING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new DateTimeViewHolder(view, this); + + case SettingsItem.TYPE_PREMIUM: + view = inflater.inflate(R.layout.premium_item_setting, parent, false); + return new PremiumViewHolder(view, this, mView); + + default: + Log.error("[SettingsAdapter] Invalid view type: " + viewType); + return null; + } + } + + @Override + public void onBindViewHolder(SettingViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + private SettingsItem getItem(int position) { + return mSettings.get(position); + } + + @Override + public int getItemCount() { + if (mSettings != null) { + return mSettings.size(); + } else { + return 0; + } + } + + @Override + public int getItemViewType(int position) { + return getItem(position).getType(); + } + + public void setSettings(ArrayList settings) { + mSettings = settings; + notifyDataSetChanged(); + } + + public void onBooleanClick(CheckBoxSetting item, int position, boolean checked) { + IntSetting setting = item.setChecked(checked); + notifyItemChanged(position); + + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item) { + mClickedItem = item; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), item.getSelectValueIndex(), this); + + mDialog = builder.show(); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onStringSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item)); + } + + DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog(); + + public void onDateTimeClick(DateTimeSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.sysclock_datetime_picker, null); + + DatePicker dp = view.findViewById(R.id.date_picker); + TimePicker tp = view.findViewById(R.id.time_picker); + + //set date and time to substrings of settingValue; format = 2018-12-24 04:20:69 (alright maybe not that 69) + String settingValue = item.getValue(); + dp.updateDate(Integer.parseInt(settingValue.substring(0, 4)), Integer.parseInt(settingValue.substring(5, 7)) - 1, Integer.parseInt(settingValue.substring(8, 10))); + + tp.setIs24HourView(true); + tp.setHour(Integer.parseInt(settingValue.substring(11, 13))); + tp.setMinute(Integer.parseInt(settingValue.substring(14, 16))); + + DialogInterface.OnClickListener ok = (dialog, which) -> { + //set it + int year = dp.getYear(); + if (year < 2000) { + year = 2000; + } + String month = ("00" + (dp.getMonth() + 1)).substring(String.valueOf(dp.getMonth() + 1).length()); + String day = ("00" + dp.getDayOfMonth()).substring(String.valueOf(dp.getDayOfMonth()).length()); + String hr = ("00" + tp.getHour()).substring(String.valueOf(tp.getHour()).length()); + String min = ("00" + tp.getMinute()).substring(String.valueOf(tp.getMinute()).length()); + String datetime = year + "-" + month + "-" + day + " " + hr + ":" + min + ":01"; + + StringSetting setting = item.setSelectedValue(datetime); + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + + mClickedItem = null; + closeDialog(); + }; + + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, ok); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + mDialog = builder.show(); + } + + public void onSliderClick(SliderSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + mSeekbarProgress = item.getSelectedValue(); + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + SeekBar seekbar = view.findViewById(R.id.seekbar); + + builder.setTitle(item.getNameId()); + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, this); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + builder.setNeutralButton(R.string.slider_default, (DialogInterface dialog, int which) -> { + seekbar.setProgress(item.getDefaultValue()); + onClick(dialog, which); + }); + mDialog = builder.show(); + + mTextSliderValue = view.findViewById(R.id.text_value); + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + + TextView units = view.findViewById(R.id.text_units); + units.setText(item.getUnits()); + + seekbar.setMin(item.getMin()); + seekbar.setMax(item.getMax()); + seekbar.setProgress(mSeekbarProgress); + + seekbar.setOnSeekBarChangeListener(this); + } + + public void onSubmenuClick(SubmenuSetting item) { + mView.loadSubMenu(item.getMenuKey()); + } + + public void onInputBindingClick(final InputBindingSetting item, final int position) { + final MotionAlertDialog dialog = new MotionAlertDialog(mContext, item); + dialog.setTitle(R.string.input_binding); + + int messageResId = R.string.input_binding_description; + if (item.IsAxisMappingSupported() && !item.IsTrigger()) { + // Use specialized message for axis left/right or up/down + if (item.IsHorizontalOrientation()) { + messageResId = R.string.input_binding_description_horizontal_axis; + } else { + messageResId = R.string.input_binding_description_vertical_axis; + } + } + + dialog.setMessage(String.format(mContext.getString(messageResId), mContext.getString(item.getNameId()))); + dialog.setButton(AlertDialog.BUTTON_NEGATIVE, mContext.getString(android.R.string.cancel), this); + dialog.setButton(AlertDialog.BUTTON_NEUTRAL, mContext.getString(R.string.clear), (dialogInterface, i) -> + item.removeOldMapping()); + dialog.setOnDismissListener(dialog1 -> + { + StringSetting setting = new StringSetting(item.getKey(), item.getSection(), item.getValue()); + notifyItemChanged(position); + + mView.putSetting(setting); + + mView.onSettingChanged(); + }); + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (mClickedItem instanceof SingleChoiceSetting) { + SingleChoiceSetting scSetting = (SingleChoiceSetting) mClickedItem; + + int value = getValueForSingleChoiceSelection(scSetting, which); + if (scSetting.getSelectedValue() != value) { + mView.onSettingChanged(); + } + + // Get the backing Setting, which may be null (if for example it was missing from the file) + IntSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem; + scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which)); + closeDialog(); + } else if (mClickedItem instanceof StringSingleChoiceSetting) { + StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem; + String value = scSetting.getValueAt(which); + if (!scSetting.getSelectedValue().equals(value)) + mView.onSettingChanged(); + + StringSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof SliderSetting) { + SliderSetting sliderSetting = (SliderSetting) mClickedItem; + if (sliderSetting.getSelectedValue() != mSeekbarProgress) { + mView.onSettingChanged(); + } + + if (sliderSetting.getSetting() instanceof FloatSetting) { + float value = (float) mSeekbarProgress; + + FloatSetting setting = sliderSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + } else { + IntSetting setting = sliderSetting.setSelectedValue(mSeekbarProgress); + if (setting != null) { + mView.putSetting(setting); + } + } + + closeDialog(); + } + + mClickedItem = null; + mSeekbarProgress = -1; + } + + public void closeDialog() { + if (mDialog != null) { + if (mClickedPosition != -1) { + notifyItemChanged(mClickedPosition); + mClickedPosition = -1; + } + mDialog.dismiss(); + mDialog = null; + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + mSeekbarProgress = progress; + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + + private int getValueForSingleChoiceSelection(SingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getValueForSingleChoiceSelection(PremiumSingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } + + private int getSelectionForSingleChoiceValue(PremiumSingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java new file mode 100644 index 0000000000..5799dcb8dc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java @@ -0,0 +1,136 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.ui.DividerItemDecoration; + +import java.util.ArrayList; + +public final class SettingsFragment extends Fragment implements SettingsFragmentView { + private static final String ARGUMENT_MENU_TAG = "menu_tag"; + private static final String ARGUMENT_GAME_ID = "game_id"; + + private SettingsFragmentPresenter mPresenter = new SettingsFragmentPresenter(this); + private SettingsActivityView mActivity; + + private SettingsAdapter mAdapter; + + public static Fragment newInstance(String menuTag, String gameId) { + SettingsFragment fragment = new SettingsFragment(); + + Bundle arguments = new Bundle(); + arguments.putString(ARGUMENT_MENU_TAG, menuTag); + arguments.putString(ARGUMENT_GAME_ID, gameId); + + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + mActivity = (SettingsActivityView) context; + mPresenter.onAttach(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setRetainInstance(true); + String menuTag = getArguments().getString(ARGUMENT_MENU_TAG); + String gameId = getArguments().getString(ARGUMENT_GAME_ID); + + mAdapter = new SettingsAdapter(this, getActivity()); + + mPresenter.onCreate(menuTag, gameId); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_settings, container, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + + RecyclerView recyclerView = view.findViewById(R.id.list_settings); + + recyclerView.setAdapter(mAdapter); + recyclerView.setLayoutManager(manager); + recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null)); + + SettingsActivityView activity = (SettingsActivityView) getActivity(); + + mPresenter.onViewCreated(activity.getSettings()); + } + + @Override + public void onDetach() { + super.onDetach(); + mActivity = null; + + if (mAdapter != null) { + mAdapter.closeDialog(); + } + } + + @Override + public void onSettingsFileLoaded(Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void passSettingsToActivity(Settings settings) { + if (mActivity != null) { + mActivity.setSettings(settings); + } + } + + @Override + public void showSettingsList(ArrayList settingsList) { + mAdapter.setSettings(settingsList); + } + + @Override + public void loadDefaultSettings() { + mPresenter.loadDefaultSettings(); + } + + @Override + public void loadSubMenu(String menuKey) { + mActivity.showSettingsFragment(menuKey, true, getArguments().getString(ARGUMENT_GAME_ID)); + } + + @Override + public void showToastMessage(String message, boolean is_long) { + mActivity.showToastMessage(message, is_long); + } + + @Override + public void putSetting(Setting setting) { + mPresenter.putSetting(setting); + } + + @Override + public void onSettingChanged() { + mActivity.onSettingChanged(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java new file mode 100644 index 0000000000..31f3e68ebc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java @@ -0,0 +1,416 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.Activity; +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.text.TextUtils; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.HeaderSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumHeader; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Objects; + +public final class SettingsFragmentPresenter { + private SettingsFragmentView mView; + + private String mMenuTag; + private String mGameID; + + private Settings mSettings; + private ArrayList mSettingsList; + + public SettingsFragmentPresenter(SettingsFragmentView view) { + mView = view; + } + + public void onCreate(String menuTag, String gameId) { + mGameID = gameId; + mMenuTag = menuTag; + } + + public void onViewCreated(Settings settings) { + setSettings(settings); + } + + /** + * If the screen is rotated, the Activity will forget the settings map. This fragment + * won't, though; so rather than have the Activity reload from disk, have the fragment pass + * the settings map back to the Activity. + */ + public void onAttach() { + if (mSettings != null) { + mView.passSettingsToActivity(mSettings); + } + } + + public void putSetting(Setting setting) { + mSettings.getSection(setting.getSection()).putSetting(setting); + } + + private StringSetting asStringSetting(Setting setting) { + if (setting == null) { + return null; + } + + StringSetting stringSetting = new StringSetting(setting.getKey(), setting.getSection(), setting.getValueAsString()); + putSetting(stringSetting); + return stringSetting; + } + + public void loadDefaultSettings() { + loadSettingsList(); + } + + public void setSettings(Settings settings) { + if (mSettingsList == null && settings != null) { + mSettings = settings; + + loadSettingsList(); + } else { + mView.getActivity().setTitle(R.string.preferences_settings); + mView.showSettingsList(mSettingsList); + } + } + + private void loadSettingsList() { + if (!TextUtils.isEmpty(mGameID)) { + mView.getActivity().setTitle("Game Settings: " + mGameID); + } + ArrayList sl = new ArrayList<>(); + + if (mMenuTag == null) { + return; + } + + switch (mMenuTag) { + case SettingsFile.FILE_NAME_CONFIG: + addConfigSettings(sl); + break; + case Settings.SECTION_PREMIUM: + addPremiumSettings(sl); + break; + case Settings.SECTION_CORE: + addGeneralSettings(sl); + break; + case Settings.SECTION_SYSTEM: + addSystemSettings(sl); + break; + case Settings.SECTION_CAMERA: + addCameraSettings(sl); + break; + case Settings.SECTION_CONTROLS: + addInputSettings(sl); + break; + case Settings.SECTION_RENDERER: + addGraphicsSettings(sl); + break; + case Settings.SECTION_AUDIO: + addAudioSettings(sl); + break; + case Settings.SECTION_DEBUG: + addDebugSettings(sl); + break; + default: + mView.showToastMessage("Unimplemented menu", false); + return; + } + + mSettingsList = sl; + mView.showSettingsList(mSettingsList); + } + + private void addConfigSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_settings); + + sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_controls, 0, Settings.SECTION_CONTROLS)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_graphics, 0, Settings.SECTION_RENDERER)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_audio, 0, Settings.SECTION_AUDIO)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG)); + } + + private void addPremiumSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_premium); + + SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM); + Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN); + + sl.add(new PremiumHeader()); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView)); + } else { + // Pre-Android 10 does not support System Default + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView)); + } + + //Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME); + //sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, R.string.texture_filter_description, textureFilterNames, textureFilterNames, "none", textureFilterName)); + } + + private void addGeneralSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_general); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting frameLimitEnable = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED); + Setting frameLimitValue = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED, Settings.SECTION_RENDERER, R.string.frame_limit_enable, R.string.frame_limit_enable_description, true, frameLimitEnable)); + sl.add(new SliderSetting(SettingsFile.KEY_FRAME_LIMIT, Settings.SECTION_RENDERER, R.string.frame_limit_slider, R.string.frame_limit_slider_description, 1, 200, "%", 100, frameLimitValue)); + } + + private void addSystemSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_system); + + SettingSection systemSection = mSettings.getSection(Settings.SECTION_SYSTEM); + Setting region = systemSection.getSetting(SettingsFile.KEY_REGION_VALUE); + Setting language = systemSection.getSetting(SettingsFile.KEY_LANGUAGE); + Setting systemClock = systemSection.getSetting(SettingsFile.KEY_INIT_CLOCK); + Setting dateTime = systemSection.getSetting(SettingsFile.KEY_INIT_TIME); + + sl.add(new SingleChoiceSetting(SettingsFile.KEY_REGION_VALUE, Settings.SECTION_SYSTEM, R.string.emulated_region, 0, R.array.regionNames, R.array.regionValues, -1, region)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_LANGUAGE, Settings.SECTION_SYSTEM, R.string.emulated_language, 0, R.array.languageNames, R.array.languageValues, 1, language)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_INIT_CLOCK, Settings.SECTION_SYSTEM, R.string.init_clock, R.string.init_clock_description, R.array.systemClockNames, R.array.systemClockValues, 0, systemClock)); + sl.add(new DateTimeSetting(SettingsFile.KEY_INIT_TIME, Settings.SECTION_SYSTEM, R.string.init_time, R.string.init_time_description, "2000-01-01 00:00:01", dateTime)); + } + + private void addCameraSettings(ArrayList sl) { + final Activity activity = mView.getActivity(); + activity.setTitle(R.string.preferences_camera); + + // Get the camera IDs + CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + ArrayList supportedCameraNameList = new ArrayList<>(); + ArrayList supportedCameraIdList = new ArrayList<>(); + if (cameraManager != null) { + try { + for (String id : cameraManager.getCameraIdList()) { + final CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + if (Objects.requireNonNull(characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)) == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + continue; // Legacy cameras cannot be used with the NDK + } + + supportedCameraIdList.add(id); + + final int facing = Objects.requireNonNull(characteristics.get(CameraCharacteristics.LENS_FACING)); + int stringId = R.string.camera_facing_external; + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + stringId = R.string.camera_facing_front; + break; + case CameraCharacteristics.LENS_FACING_BACK: + stringId = R.string.camera_facing_back; + break; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + stringId = R.string.camera_facing_external; + break; + } + supportedCameraNameList.add(String.format("%1$s (%2$s)", id, activity.getString(stringId))); + } + } catch (CameraAccessException e) { + Log.error("Couldn't retrieve camera list"); + e.printStackTrace(); + } + } + + // Create the names and values for display + ArrayList cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames))); + cameraDeviceNameList.addAll(supportedCameraNameList); + ArrayList cameraDeviceValueList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceValues))); + cameraDeviceValueList.addAll(supportedCameraIdList); + + final String[] cameraDeviceNames = cameraDeviceNameList.toArray(new String[]{}); + final String[] cameraDeviceValues = cameraDeviceValueList.toArray(new String[]{}); + + final boolean haveCameraDevices = !supportedCameraIdList.isEmpty(); + + String[] imageSourceNames = activity.getResources().getStringArray(R.array.cameraImageSourceNames); + String[] imageSourceValues = activity.getResources().getStringArray(R.array.cameraImageSourceValues); + if (!haveCameraDevices) { + // Remove the last entry (ndk / Device Camera) + imageSourceNames = Arrays.copyOfRange(imageSourceNames, 0, imageSourceNames.length - 1); + imageSourceValues = Arrays.copyOfRange(imageSourceValues, 0, imageSourceValues.length - 1); + } + + final String defaultImageSource = haveCameraDevices ? "ndk" : "image"; + + SettingSection cameraSection = mSettings.getSection(Settings.SECTION_CAMERA); + + Setting innerCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_NAME); + Setting innerCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG)); + Setting innerCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_FLIP); + sl.add(new HeaderSetting(null, null, R.string.inner_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, innerCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_front", innerCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, innerCameraFlip)); + + Setting outerLeftCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME); + Setting outerLeftCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG)); + Setting outerLeftCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_left_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerLeftCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerLeftCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerLeftCameraFlip)); + + Setting outerRightCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME); + Setting outerRightCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG)); + Setting outerRightCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_right_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerRightCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerRightCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerRightCameraFlip)); + } + + private void addInputSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_controls); + + SettingSection controlsSection = mSettings.getSection(Settings.SECTION_CONTROLS); + Setting buttonA = controlsSection.getSetting(SettingsFile.KEY_BUTTON_A); + Setting buttonB = controlsSection.getSetting(SettingsFile.KEY_BUTTON_B); + Setting buttonX = controlsSection.getSetting(SettingsFile.KEY_BUTTON_X); + Setting buttonY = controlsSection.getSetting(SettingsFile.KEY_BUTTON_Y); + Setting buttonSelect = controlsSection.getSetting(SettingsFile.KEY_BUTTON_SELECT); + Setting buttonStart = controlsSection.getSetting(SettingsFile.KEY_BUTTON_START); + Setting circlepadAxisVert = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL); + Setting circlepadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL); + Setting cstickAxisVert = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL); + Setting cstickAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL); + Setting dpadAxisVert = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL); + Setting dpadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL); + // Setting buttonUp = controlsSection.getSetting(SettingsFile.KEY_BUTTON_UP); + // Setting buttonDown = controlsSection.getSetting(SettingsFile.KEY_BUTTON_DOWN); + // Setting buttonLeft = controlsSection.getSetting(SettingsFile.KEY_BUTTON_LEFT); + // Setting buttonRight = controlsSection.getSetting(SettingsFile.KEY_BUTTON_RIGHT); + Setting buttonL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_L); + Setting buttonR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_R); + Setting buttonZL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZL); + Setting buttonZR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZR); + + sl.add(new HeaderSetting(null, null, R.string.generic_buttons, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_A, Settings.SECTION_CONTROLS, R.string.button_a, buttonA)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_B, Settings.SECTION_CONTROLS, R.string.button_b, buttonB)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_X, Settings.SECTION_CONTROLS, R.string.button_x, buttonX)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_Y, Settings.SECTION_CONTROLS, R.string.button_y, buttonY)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_SELECT, Settings.SECTION_CONTROLS, R.string.button_select, buttonSelect)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_START, Settings.SECTION_CONTROLS, R.string.button_start, buttonStart)); + + sl.add(new HeaderSetting(null, null, R.string.controller_circlepad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, circlepadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, circlepadAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_c, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, cstickAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, cstickAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_dpad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, dpadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, dpadAxisHoriz)); + + // TODO(bunnei): Figure out what to do with these. Configuring is functional, but removing for MVP because they are confusing. + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_UP, Settings.SECTION_CONTROLS, R.string.generic_up, buttonUp)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_DOWN, Settings.SECTION_CONTROLS, R.string.generic_down, buttonDown)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_LEFT, Settings.SECTION_CONTROLS, R.string.generic_left, buttonLeft)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_RIGHT, Settings.SECTION_CONTROLS, R.string.generic_right, buttonRight)); + + sl.add(new HeaderSetting(null, null, R.string.controller_triggers, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_L, Settings.SECTION_CONTROLS, R.string.button_l, buttonL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_R, Settings.SECTION_CONTROLS, R.string.button_r, buttonR)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZL, Settings.SECTION_CONTROLS, R.string.button_zl, buttonZL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZR, Settings.SECTION_CONTROLS, R.string.button_zr, buttonZR)); + } + + private void addGraphicsSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_graphics); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting resolutionFactor = rendererSection.getSetting(SettingsFile.KEY_RESOLUTION_FACTOR); + Setting filterMode = rendererSection.getSetting(SettingsFile.KEY_FILTER_MODE); + Setting shadersAccurateMul = rendererSection.getSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL); + Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D); + Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D); + Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE); + SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT); + Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE); + Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT); + Setting cardboardYShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT); + SettingSection utilitySection = mSettings.getSection(Settings.SECTION_UTILITY); + Setting dumpTextures = utilitySection.getSetting(SettingsFile.KEY_DUMP_TEXTURES); + Setting customTextures = utilitySection.getSetting(SettingsFile.KEY_CUSTOM_TEXTURES); + //Setting preloadTextures = utilitySection.getSetting(SettingsFile.KEY_PRELOAD_TEXTURES); + + sl.add(new HeaderSetting(null, null, R.string.renderer, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_RESOLUTION_FACTOR, Settings.SECTION_RENDERER, R.string.internal_resolution, R.string.internal_resolution_description, 1, 4, "x", 1, resolutionFactor)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache)); + + sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode)); + sl.add(new SliderSetting(SettingsFile.KEY_FACTOR_3D, Settings.SECTION_RENDERER, R.string.factor3d, R.string.factor3d_description, 0, 100, "%", 0, factor3d)); + + sl.add(new HeaderSetting(null, null, R.string.cardboard_vr, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE, Settings.SECTION_LAYOUT, R.string.cardboard_screen_size, R.string.cardboard_screen_size_description, 30, 100, "%", 85, cardboardScreenSize)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_x_shift, R.string.cardboard_x_shift_description, -100, 100, "%", 0, cardboardXShift)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_y_shift, R.string.cardboard_y_shift_description, -100, 100, "%", 0, cardboardYShift)); + + sl.add(new HeaderSetting(null, null, R.string.utility, 0)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_DUMP_TEXTURES, Settings.SECTION_UTILITY, R.string.dump_textures, R.string.dump_textures_description, false, dumpTextures)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_CUSTOM_TEXTURES, Settings.SECTION_UTILITY, R.string.custom_textures, R.string.custom_textures_description, false, customTextures)); + //Disabled until custom texture implementation gets rewrite, current one overloads RAM and crashes Citra. + //sl.add(new CheckBoxSetting(SettingsFile.KEY_PRELOAD_TEXTURES, Settings.SECTION_UTILITY, R.string.preload_textures, R.string.preload_textures_description, false, preloadTextures)); + } + + private void addAudioSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_audio); + + SettingSection audioSection = mSettings.getSection(Settings.SECTION_AUDIO); + Setting audioStretch = audioSection.getSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING); + Setting micInputType = audioSection.getSetting(SettingsFile.KEY_MIC_INPUT_TYPE); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING, Settings.SECTION_AUDIO, R.string.audio_stretch, R.string.audio_stretch_description, true, audioStretch)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_MIC_INPUT_TYPE, Settings.SECTION_AUDIO, R.string.audio_input_type, 0, R.array.audioInputTypeNames, R.array.audioInputTypeValues, 1, micInputType)); + } + + private void addDebugSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_debug); + + SettingSection coreSection = mSettings.getSection(Settings.SECTION_CORE); + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting useCpuJit = coreSection.getSetting(SettingsFile.KEY_CPU_JIT); + Setting hardwareRenderer = rendererSection.getSetting(SettingsFile.KEY_HW_RENDERER); + Setting hardwareShader = rendererSection.getSetting(SettingsFile.KEY_HW_SHADER); + Setting vsyncEnable = rendererSection.getSetting(SettingsFile.KEY_USE_VSYNC); + + sl.add(new HeaderSetting(null, null, R.string.debug_warning, 0)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_CPU_JIT, Settings.SECTION_CORE, R.string.cpu_jit, R.string.cpu_jit_description, true, useCpuJit, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_RENDERER, Settings.SECTION_RENDERER, R.string.hw_renderer, R.string.hw_renderer_description, true, hardwareRenderer, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_SHADER, Settings.SECTION_RENDERER, R.string.hw_shaders, R.string.hw_shaders_description, true, hardwareShader, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_VSYNC, Settings.SECTION_RENDERER, R.string.vsync, R.string.vsync_description, true, vsyncEnable)); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java new file mode 100644 index 0000000000..c36eb55a7c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java @@ -0,0 +1,78 @@ +package org.citra.citra_emu.features.settings.ui; + +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; + +import java.util.ArrayList; + +/** + * Abstraction for a screen showing a list of settings. Instances of + * this type of view will each display a layer of the setting hierarchy. + */ +public interface SettingsFragmentView { + /** + * Called by the containing Activity to notify the Fragment that an + * asynchronous load operation completed. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Pass a settings HashMap to the containing activity, so that it can + * share the HashMap with other SettingsFragments; useful so that rotations + * do not require an additional load operation. + * + * @param settings An ArrayList containing all the settings HashMaps. + */ + void passSettingsToActivity(Settings settings); + + /** + * Pass an ArrayList to the View so that it can be displayed on screen. + * + * @param settingsList The result of converting the HashMap to an ArrayList + */ + void showSettingsList(ArrayList settingsList); + + /** + * Called by the containing Activity when an asynchronous load operation fails. + * Instructs the Fragment to load the settings screen with defaults selected. + */ + void loadDefaultSettings(); + + /** + * @return The Fragment's containing activity. + */ + FragmentActivity getActivity(); + + /** + * Tell the Fragment to tell the containing Activity to show a new + * Fragment containing a submenu of settings. + * + * @param menuKey Identifier for the settings group that should be shown. + */ + void loadSubMenu(String menuKey); + + /** + * Tell the Fragment to tell the containing activity to display a toast message. + * + * @param message Text to be shown in the Toast + * @param is_long Whether this should be a long Toast or short one. + */ + void showToastMessage(String message, boolean is_long); + + /** + * Have the fragment add a setting to the HashMap. + * + * @param setting The (possibly previously missing) new setting. + */ + void putSetting(Setting setting); + + /** + * Have the fragment tell the containing Activity that a setting was modified. + */ + void onSettingChanged(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java new file mode 100644 index 0000000000..67bde57099 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java @@ -0,0 +1,48 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +/** + * FrameLayout subclass with few Properties added to simplify animations. + * Don't remove the methods appearing as unused, in order not to break the menu animations + */ +public final class SettingsFrameLayout extends FrameLayout { + private float mVisibleness = 1.0f; + + public SettingsFrameLayout(Context context) { + super(context); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public float getYFraction() { + return getY() / getHeight(); + } + + public void setYFraction(float yFraction) { + final int height = getHeight(); + setY((height > 0) ? (yFraction * height) : -9999); + } + + public float getVisibleness() { + return mVisibleness; + } + + public void setVisibleness(float visibleness) { + setScaleX(visibleness); + setScaleY(visibleness); + setAlpha(visibleness); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java new file mode 100644 index 0000000000..d914f7d0bc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java @@ -0,0 +1,54 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class CheckBoxSettingViewHolder extends SettingViewHolder { + private CheckBoxSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private CheckBox mCheckbox; + + public CheckBoxSettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + mCheckbox = root.findViewById(R.id.checkbox); + } + + @Override + public void bind(SettingsItem item) { + mItem = (CheckBoxSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setText(""); + mTextSettingDescription.setVisibility(View.GONE); + } + + mCheckbox.setChecked(mItem.isChecked()); + } + + @Override + public void onClick(View clicked) { + mCheckbox.toggle(); + + getAdapter().onBooleanClick(mItem, getAdapterPosition(), mCheckbox.isChecked()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java new file mode 100644 index 0000000000..09ea93010e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java @@ -0,0 +1,47 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.utils.Log; + +public final class DateTimeViewHolder extends SettingViewHolder { + private DateTimeSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public DateTimeViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + Log.error("test " + mTextSettingName); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + Log.error("test " + mTextSettingDescription); + } + + @Override + public void bind(SettingsItem item) { + mItem = (DateTimeSetting) item; + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onDateTimeClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java new file mode 100644 index 0000000000..baf80ed76d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java @@ -0,0 +1,32 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class HeaderViewHolder extends SettingViewHolder { + private TextView mHeaderName; + + public HeaderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + itemView.setOnClickListener(null); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_header_name); + } + + @Override + public void bind(SettingsItem item) { + mHeaderName.setText(item.getNameId()); + } + + @Override + public void onClick(View clicked) { + // no-op + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java new file mode 100644 index 0000000000..7d95c250a1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class InputBindingSettingViewHolder extends SettingViewHolder { + private InputBindingSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private Context mContext; + + public InputBindingSettingViewHolder(View itemView, SettingsAdapter adapter, Context context) { + super(itemView, adapter); + + mContext = context; + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); + + mItem = (InputBindingSetting) item; + + mTextSettingName.setText(item.getNameId()); + + String key = sharedPreferences.getString(mItem.getKey(), ""); + if (key != null && !key.isEmpty()) { + mTextSettingDescription.setText(key); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onInputBindingClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java new file mode 100644 index 0000000000..be0853ff0e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; +import org.citra.citra_emu.ui.main.MainActivity; + +public final class PremiumViewHolder extends SettingViewHolder { + private TextView mHeaderName; + private TextView mTextDescription; + private SettingsFragmentView mView; + + public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) { + super(itemView, adapter); + mView = view; + itemView.setOnClickListener(this); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_setting_name); + mTextDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + updateText(); + } + + @Override + public void onClick(View clicked) { + if (MainActivity.isPremiumActive()) { + return; + } + + // Invoke billing flow if Premium is not already active, then refresh the UI to indicate + // the purchase has completed. + MainActivity.invokePremiumBilling(() -> updateText()); + } + + /** + * Update the text shown to the user, based on whether Premium is active + */ + private void updateText() { + if (MainActivity.isPremiumActive()) { + mHeaderName.setText(R.string.premium_settings_welcome); + mTextDescription.setText(R.string.premium_settings_welcome_description); + } else { + mHeaderName.setText(R.string.premium_settings_upsell); + mTextDescription.setText(R.string.premium_settings_upsell_description); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java new file mode 100644 index 0000000000..2643ea1214 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java @@ -0,0 +1,49 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public abstract class SettingViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + private SettingsAdapter mAdapter; + + public SettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView); + + mAdapter = adapter; + + itemView.setOnClickListener(this); + + findViews(itemView); + } + + protected SettingsAdapter getAdapter() { + return mAdapter; + } + + /** + * Gets handles to all this ViewHolder's child views using their XML-defined identifiers. + * + * @param root The newly inflated top-level view. + */ + protected abstract void findViews(View root); + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + public abstract void bind(SettingsItem item); + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + public abstract void onClick(View clicked); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java new file mode 100644 index 0000000000..a175af9f83 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.res.Resources; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SingleChoiceViewHolder extends SettingViewHolder { + private SettingsItem mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SingleChoiceViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = item; + + mTextSettingName.setText(item.getNameId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + } else if (item instanceof SingleChoiceSetting) { + SingleChoiceSetting setting = (SingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else if (item instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + int position = getAdapterPosition(); + if (mItem instanceof SingleChoiceSetting) { + getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position); + } else if (mItem instanceof PremiumSingleChoiceSetting) { + getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position); + } else if (mItem instanceof StringSingleChoiceSetting) { + getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java new file mode 100644 index 0000000000..3dd048a296 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SliderViewHolder extends SettingViewHolder { + private SliderSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SliderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SliderSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSliderClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java new file mode 100644 index 0000000000..cb8c3e92a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SubmenuViewHolder extends SettingViewHolder { + private SubmenuSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SubmenuViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SubmenuSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSubmenuClick(mItem); + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java new file mode 100644 index 0000000000..8ae6b70d7f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java @@ -0,0 +1,341 @@ +package org.citra.citra_emu.features.settings.utils; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.utils.BiMap; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.Log; +import org.ini4j.Wini; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +public final class SettingsFile { + public static final String FILE_NAME_CONFIG = "config"; + + public static final String KEY_CPU_JIT = "use_cpu_jit"; + + public static final String KEY_DESIGN = "design"; + + public static final String KEY_PREMIUM = "premium"; + + public static final String KEY_HW_RENDERER = "use_hw_renderer"; + public static final String KEY_HW_SHADER = "use_hw_shader"; + public static final String KEY_SHADERS_ACCURATE_MUL = "shaders_accurate_mul"; + public static final String KEY_USE_SHADER_JIT = "use_shader_jit"; + public static final String KEY_USE_DISK_SHADER_CACHE = "use_disk_shader_cache"; + public static final String KEY_USE_VSYNC = "use_vsync_new"; + public static final String KEY_RESOLUTION_FACTOR = "resolution_factor"; + public static final String KEY_FRAME_LIMIT_ENABLED = "use_frame_limit"; + public static final String KEY_FRAME_LIMIT = "frame_limit"; + public static final String KEY_BACKGROUND_RED = "bg_red"; + public static final String KEY_BACKGROUND_BLUE = "bg_blue"; + public static final String KEY_BACKGROUND_GREEN = "bg_green"; + public static final String KEY_RENDER_3D = "render_3d"; + public static final String KEY_FACTOR_3D = "factor_3d"; + public static final String KEY_PP_SHADER_NAME = "pp_shader_name"; + public static final String KEY_FILTER_MODE = "filter_mode"; + public static final String KEY_TEXTURE_FILTER_NAME = "texture_filter_name"; + public static final String KEY_USE_ASYNCHRONOUS_GPU_EMULATION = "use_asynchronous_gpu_emulation"; + + public static final String KEY_LAYOUT_OPTION = "layout_option"; + public static final String KEY_SWAP_SCREEN = "swap_screen"; + public static final String KEY_CARDBOARD_SCREEN_SIZE = "cardboard_screen_size"; + public static final String KEY_CARDBOARD_X_SHIFT = "cardboard_x_shift"; + public static final String KEY_CARDBOARD_Y_SHIFT = "cardboard_y_shift"; + + public static final String KEY_DUMP_TEXTURES = "dump_textures"; + public static final String KEY_CUSTOM_TEXTURES = "custom_textures"; + public static final String KEY_PRELOAD_TEXTURES = "preload_textures"; + + public static final String KEY_AUDIO_OUTPUT_ENGINE = "output_engine"; + public static final String KEY_ENABLE_AUDIO_STRETCHING = "enable_audio_stretching"; + public static final String KEY_VOLUME = "volume"; + public static final String KEY_MIC_INPUT_TYPE = "mic_input_type"; + + public static final String KEY_USE_VIRTUAL_SD = "use_virtual_sd"; + + public static final String KEY_IS_NEW_3DS = "is_new_3ds"; + public static final String KEY_REGION_VALUE = "region_value"; + public static final String KEY_LANGUAGE = "language"; + + public static final String KEY_INIT_CLOCK = "init_clock"; + public static final String KEY_INIT_TIME = "init_time"; + + public static final String KEY_BUTTON_A = "button_a"; + public static final String KEY_BUTTON_B = "button_b"; + public static final String KEY_BUTTON_X = "button_x"; + public static final String KEY_BUTTON_Y = "button_y"; + public static final String KEY_BUTTON_SELECT = "button_select"; + public static final String KEY_BUTTON_START = "button_start"; + public static final String KEY_BUTTON_UP = "button_up"; + public static final String KEY_BUTTON_DOWN = "button_down"; + public static final String KEY_BUTTON_LEFT = "button_left"; + public static final String KEY_BUTTON_RIGHT = "button_right"; + public static final String KEY_BUTTON_L = "button_l"; + public static final String KEY_BUTTON_R = "button_r"; + public static final String KEY_BUTTON_ZL = "button_zl"; + public static final String KEY_BUTTON_ZR = "button_zr"; + public static final String KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical"; + public static final String KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal"; + public static final String KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical"; + public static final String KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"; + public static final String KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"; + public static final String KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"; + public static final String KEY_CIRCLEPAD_UP = "circlepad_up"; + public static final String KEY_CIRCLEPAD_DOWN = "circlepad_down"; + public static final String KEY_CIRCLEPAD_LEFT = "circlepad_left"; + public static final String KEY_CIRCLEPAD_RIGHT = "circlepad_right"; + public static final String KEY_CSTICK_UP = "cstick_up"; + public static final String KEY_CSTICK_DOWN = "cstick_down"; + public static final String KEY_CSTICK_LEFT = "cstick_left"; + public static final String KEY_CSTICK_RIGHT = "cstick_right"; + + public static final String KEY_CAMERA_OUTER_RIGHT_NAME = "camera_outer_right_name"; + public static final String KEY_CAMERA_OUTER_RIGHT_CONFIG = "camera_outer_right_config"; + public static final String KEY_CAMERA_OUTER_RIGHT_FLIP = "camera_outer_right_flip"; + public static final String KEY_CAMERA_OUTER_LEFT_NAME = "camera_outer_left_name"; + public static final String KEY_CAMERA_OUTER_LEFT_CONFIG = "camera_outer_left_config"; + public static final String KEY_CAMERA_OUTER_LEFT_FLIP = "camera_outer_left_flip"; + public static final String KEY_CAMERA_INNER_NAME = "camera_inner_name"; + public static final String KEY_CAMERA_INNER_CONFIG = "camera_inner_config"; + public static final String KEY_CAMERA_INNER_FLIP = "camera_inner_flip"; + + public static final String KEY_LOG_FILTER = "log_filter"; + + private static BiMap sectionsMap = new BiMap<>(); + + static { + //TODO: Add members to sectionsMap when game-specific settings are added + } + + + private SettingsFile() { + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param ini The ini file to load the settings from + * @param isCustomGame + * @param view The current view. + * @return An Observable that emits a HashMap of the file's contents, then completes. + */ + static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) { + HashMap sections = new Settings.SettingsSectionMap(); + + BufferedReader reader = null; + + try { + reader = new BufferedReader(new FileReader(ini)); + + SettingSection current = null; + for (String line; (line = reader.readLine()) != null; ) { + if (line.startsWith("[") && line.endsWith("]")) { + current = sectionFromLine(line, isCustomGame); + sections.put(current.getName(), current); + } else if ((current != null)) { + Setting setting = settingFromLine(current, line); + if (setting != null) { + current.putSetting(setting); + } + } + } + } catch (FileNotFoundException e) { + Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } catch (IOException e) { + Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage()); + } + } + } + + return sections; + } + + public static HashMap readFile(final String fileName, SettingsActivityView view) { + return readFile(getSettingsFile(fileName), false, view); + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param gameId the id of the game to load it's settings. + * @param view The current view. + */ + public static HashMap readCustomGameSettings(final String gameId, SettingsActivityView view) { + return readFile(getCustomGameSettingsFile(gameId), true, view); + } + + /** + * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error + * telling why it failed. + * + * @param fileName The target filename without a path or extension. + * @param sections The HashMap containing the Settings we want to serialize. + * @param view The current view. + */ + public static void saveFile(final String fileName, TreeMap sections, + SettingsActivityView view) { + File ini = getSettingsFile(fileName); + + try { + Wini writer = new Wini(ini); + + Set keySet = sections.keySet(); + for (String key : keySet) { + SettingSection section = sections.get(key); + writeSection(writer, section); + } + writer.store(); + } catch (IOException e) { + Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); + } + } + + + public static void saveCustomGameSettings(final String gameId, final HashMap sections) { + Set sortedSections = new TreeSet<>(sections.keySet()); + + for (String sectionKey : sortedSections) { + SettingSection section = sections.get(sectionKey); + + HashMap settings = section.getSettings(); + Set sortedKeySet = new TreeSet<>(settings.keySet()); + + for (String settingKey : sortedKeySet) { + Setting setting = settings.get(settingKey); + NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString()); + } + } + } + + private static String mapSectionNameFromIni(String generalSectionName) { + if (sectionsMap.getForward(generalSectionName) != null) { + return sectionsMap.getForward(generalSectionName); + } + + return generalSectionName; + } + + private static String mapSectionNameToIni(String generalSectionName) { + if (sectionsMap.getBackward(generalSectionName) != null) { + return sectionsMap.getBackward(generalSectionName); + } + + return generalSectionName; + } + + @NonNull + private static File getSettingsFile(String fileName) { + return new File( + DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini"); + } + + private static File getCustomGameSettingsFile(String gameId) { + return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini"); + } + + private static SettingSection sectionFromLine(String line, boolean isCustomGame) { + String sectionName = line.substring(1, line.length() - 1); + if (isCustomGame) { + sectionName = mapSectionNameToIni(sectionName); + } + return new SettingSection(sectionName); + } + + /** + * For a line of text, determines what type of data is being represented, and returns + * a Setting object containing this data. + * + * @param current The section currently being parsed by the consuming method. + * @param line The line of text being parsed. + * @return A typed Setting containing the key/value contained in the line. + */ + private static Setting settingFromLine(SettingSection current, String line) { + String[] splitLine = line.split("="); + + if (splitLine.length != 2) { + Log.warning("Skipping invalid config line \"" + line + "\""); + return null; + } + + String key = splitLine[0].trim(); + String value = splitLine[1].trim(); + + if (value.isEmpty()) { + Log.warning("Skipping null value in config line \"" + line + "\""); + return null; + } + + try { + int valueAsInt = Integer.parseInt(value); + + return new IntSetting(key, current.getName(), valueAsInt); + } catch (NumberFormatException ex) { + } + + try { + float valueAsFloat = Float.parseFloat(value); + + return new FloatSetting(key, current.getName(), valueAsFloat); + } catch (NumberFormatException ex) { + } + + return new StringSetting(key, current.getName(), value); + } + + /** + * Writes the contents of a Section HashMap to disk. + * + * @param parser A Wini pointed at a file on disk. + * @param section A section containing settings to be written to the file. + */ + private static void writeSection(Wini parser, SettingSection section) { + // Write the section header. + String header = section.getName(); + + // Write this section's values. + HashMap settings = section.getSettings(); + Set keySet = settings.keySet(); + + for (String key : keySet) { + Setting setting = settings.get(key); + parser.put(header, setting.getKey(), setting.getValueAsString()); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java new file mode 100644 index 0000000000..c18ecd4c3d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java @@ -0,0 +1,120 @@ +package org.citra.citra_emu.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.FileProvider; + +import com.nononsenseapps.filepicker.FilePickerFragment; + +import org.citra.citra_emu.R; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class CustomFilePickerFragment extends FilePickerFragment { + private static String ALL_FILES = "*"; + private int mTitle; + private static List extensions = Collections.singletonList(ALL_FILES); + + @NonNull + @Override + public Uri toUri(@NonNull final File file) { + return FileProvider + .getUriForFile(getContext(), + getContext().getApplicationContext().getPackageName() + ".filesprovider", + file); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (mode == MODE_DIR) { + TextView ok = getActivity().findViewById(R.id.nnf_button_ok); + ok.setText(R.string.select_dir); + + TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel); + cancel.setVisibility(View.GONE); + } + } + + @Override + protected View inflateRootView(LayoutInflater inflater, ViewGroup container) { + View view = super.inflateRootView(inflater, container); + if (mTitle != 0) { + Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar); + ViewGroup parent = (ViewGroup) toolbar.getParent(); + int index = parent.indexOfChild(toolbar); + View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false); + TextView title = newToolbar.findViewById(R.id.filepicker_title); + title.setText(mTitle); + parent.removeView(toolbar); + parent.addView(newToolbar, index); + } + return view; + } + + public void setTitle(int title) { + mTitle = title; + } + + public void setAllowedExtensions(String allowedExtensions) { + if (allowedExtensions == null) + return; + + extensions = Arrays.asList(allowedExtensions.split(",")); + } + + @Override + protected boolean isItemVisible(@NonNull final File file) { + // Some users jump to the conclusion that Dolphin isn't able to detect their + // files if the files don't show up in the file picker when mode == MODE_DIR. + // To avoid this, show files even when the user needs to select a directory. + return (showHiddenItems || !file.isHidden()) && + (file.isDirectory() || extensions.contains(ALL_FILES) || + extensions.contains(fileExtension(file.getName()).toLowerCase())); + } + + @Override + public boolean isCheckable(@NonNull final File file) { + // We need to make a small correction to the isCheckable logic due to + // overriding isItemVisible to show files when mode == MODE_DIR. + // AbstractFilePickerFragment always treats files as checkable when + // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR. + return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile()); + } + + @Override + public void goUp() { + if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) { + goToDir(new File("/storage/")); + return; + } + if (mCurrentPath.equals(new File("/storage/"))){ + return; + } + super.goUp(); + } + + @Override + public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) { + if(viewHolder.file.equals(new File("/storage/emulated/"))) + viewHolder.file = new File("/storage/emulated/0/"); + super.onClickDir(view, viewHolder); + } + + private static String fileExtension(@NonNull String filename) { + int i = filename.lastIndexOf('.'); + return i < 0 ? "" : filename.substring(i + 1); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java new file mode 100644 index 0000000000..cdb40d6f85 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java @@ -0,0 +1,380 @@ +package org.citra.citra_emu.fragments; + +import android.content.Context; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.view.Choreographer; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.overlay.InputOverlay; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; + +public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback, Choreographer.FrameCallback { + private static final String KEY_GAMEPATH = "gamepath"; + + private static final Handler perfStatsUpdateHandler = new Handler(); + + private SharedPreferences mPreferences; + + private InputOverlay mInputOverlay; + + private EmulationState mEmulationState; + + private DirectoryStateReceiver directoryStateReceiver; + + private EmulationActivity activity; + + private TextView mPerfStats; + + private Runnable perfStatsUpdater; + + public static EmulationFragment newInstance(String gamePath) { + Bundle args = new Bundle(); + args.putString(KEY_GAMEPATH, gamePath); + + EmulationFragment fragment = new EmulationFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof EmulationActivity) { + activity = (EmulationActivity) context; + NativeLibrary.setEmulationActivity((EmulationActivity) context); + } else { + throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + setRetainInstance(true); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + + String gamePath = getArguments().getString(KEY_GAMEPATH); + mEmulationState = new EmulationState(gamePath); + } + + /** + * Initialize the UI and start emulation in here. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View contents = inflater.inflate(R.layout.fragment_emulation, container, false); + + SurfaceView surfaceView = contents.findViewById(R.id.surface_emulation); + surfaceView.getHolder().addCallback(this); + + mInputOverlay = contents.findViewById(R.id.surface_input_overlay); + mPerfStats = contents.findViewById(R.id.show_fps_text); + mPerfStats.setTextColor(Color.YELLOW); + + Button doneButton = contents.findViewById(R.id.done_control_config); + if (doneButton != null) { + doneButton.setOnClickListener(v -> stopConfiguringControls()); + } + + // Show/hide the "Show FPS" overlay + updateShowFpsOverlay(); + + // The new Surface created here will get passed to the native code via onSurfaceChanged. + return contents; + } + + @Override + public void onResume() { + super.onResume(); + Choreographer.getInstance().postFrameCallback(this); + if (DirectoryInitialization.areCitraDirectoriesReady()) { + mEmulationState.run(activity.isActivityRecreated()); + } else { + setupCitraDirectoriesThenStartEmulation(); + } + } + + @Override + public void onPause() { + if (directoryStateReceiver != null) { + LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mEmulationState.isRunning()) { + mEmulationState.pause(); + } + + Choreographer.getInstance().removeFrameCallback(this); + super.onPause(); + } + + @Override + public void onDetach() { + NativeLibrary.clearEmulationActivity(); + super.onDetach(); + } + + private void setupCitraDirectoriesThenStartEmulation() { + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == + DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mEmulationState.run(activity.isActivityRecreated()); + } else if (directoryInitializationState == + DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } else if (directoryInitializationState == + DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + Toast.makeText(getContext(), R.string.external_storage_not_mounted, + Toast.LENGTH_SHORT) + .show(); + } + }); + + // Registers the DirectoryStateReceiver and its intent filters + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + directoryStateReceiver, + statusIntentFilter); + DirectoryInitialization.start(getActivity()); + } + + public void refreshInputOverlay() { + mInputOverlay.refreshControls(); + } + + public void resetInputOverlay() { + // Reset button scale + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", 50); + editor.apply(); + + mInputOverlay.resetButtonPlacement(); + } + + public void updateShowFpsOverlay() { + if (true) { + final int SYSTEM_FPS = 0; + final int FPS = 1; + final int FRAMETIME = 2; + final int SPEED = 3; + + perfStatsUpdater = () -> + { + final double[] perfStats = NativeLibrary.GetPerfStats(); + if (perfStats[FPS] > 0) { + mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS]), + (int) (perfStats[SPEED] * 100.0))); + } + + perfStatsUpdateHandler.postDelayed(perfStatsUpdater, 3000); + }; + perfStatsUpdateHandler.post(perfStatsUpdater); + + mPerfStats.setVisibility(View.VISIBLE); + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater); + } + + mPerfStats.setVisibility(View.GONE); + } + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height); + mEmulationState.newSurface(holder.getSurface()); + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + mEmulationState.clearSurface(); + } + + @Override + public void doFrame(long frameTimeNanos) { + Choreographer.getInstance().postFrameCallback(this); + NativeLibrary.DoFrame(); + } + + public void stopEmulation() { + mEmulationState.stop(); + } + + public void startConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.VISIBLE); + mInputOverlay.setIsInEditMode(true); + } + + public void stopConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.GONE); + mInputOverlay.setIsInEditMode(false); + } + + public boolean isConfiguringControls() { + return mInputOverlay.isInEditMode(); + } + + private static class EmulationState { + private final String mGamePath; + private State state; + private Surface mSurface; + private boolean mRunWhenSurfaceIsValid; + + EmulationState(String gamePath) { + mGamePath = gamePath; + // Starting state is stopped. + state = State.STOPPED; + } + + public synchronized boolean isStopped() { + return state == State.STOPPED; + } + + // Getters for the current state + + public synchronized boolean isPaused() { + return state == State.PAUSED; + } + + public synchronized boolean isRunning() { + return state == State.RUNNING; + } + + public synchronized void stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation."); + state = State.STOPPED; + NativeLibrary.StopEmulation(); + } else { + Log.warning("[EmulationFragment] Stop called while already stopped."); + } + } + + // State changing methods + + public synchronized void pause() { + if (state != State.PAUSED) { + state = State.PAUSED; + Log.debug("[EmulationFragment] Pausing emulation."); + + // Release the surface before pausing, since emulation has to be running for that. + NativeLibrary.SurfaceDestroyed(); + NativeLibrary.PauseEmulation(); + } else { + Log.warning("[EmulationFragment] Pause called while already paused."); + } + } + + public synchronized void run(boolean isActivityRecreated) { + if (isActivityRecreated) { + if (NativeLibrary.IsRunning()) { + state = State.PAUSED; + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start"); + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (mSurface != null) { + runWithValidSurface(); + } else { + mRunWhenSurfaceIsValid = true; + } + } + + // Surface callbacks + public synchronized void newSurface(Surface surface) { + mSurface = surface; + if (mRunWhenSurfaceIsValid) { + runWithValidSurface(); + } + } + + public synchronized void clearSurface() { + if (mSurface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null."); + } else { + mSurface = null; + Log.debug("[EmulationFragment] Surface destroyed."); + + if (state == State.RUNNING) { + NativeLibrary.SurfaceDestroyed(); + state = State.PAUSED; + } else if (state == State.PAUSED) { + Log.warning("[EmulationFragment] Surface cleared while emulation paused."); + } else { + Log.warning("[EmulationFragment] Surface cleared while emulation stopped."); + } + } + } + + private void runWithValidSurface() { + mRunWhenSurfaceIsValid = false; + if (state == State.STOPPED) { + NativeLibrary.SurfaceChanged(mSurface); + Thread mEmulationThread = new Thread(() -> + { + Log.debug("[EmulationFragment] Starting emulation thread."); + NativeLibrary.Run(mGamePath); + }, "NativeEmulation"); + mEmulationThread.start(); + + } else if (state == State.PAUSED) { + Log.debug("[EmulationFragment] Resuming emulation."); + NativeLibrary.SurfaceChanged(mSurface); + NativeLibrary.UnPauseEmulation(); + } else { + Log.debug("[EmulationFragment] Bug, run called while already running."); + } + state = State.RUNNING; + } + + private enum State { + STOPPED, RUNNING, PAUSED + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java new file mode 100644 index 0000000000..a4ffc59c71 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import java.nio.file.Paths; + +public final class Game { + private String mTitle; + private String mDescription; + private String mPath; + private String mGameId; + private String mCompany; + private String mRegions; + + public Game(String title, String description, String regions, String path, + String gameId, String company) { + mTitle = title; + mDescription = description; + mRegions = regions; + mPath = path; + mGameId = gameId; + mCompany = company; + } + + public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) { + ContentValues values = new ContentValues(); + + if (gameId.isEmpty()) { + // Homebrew, etc. may not have a game ID, use filename as a unique identifier + gameId = Paths.get(path).getFileName().toString(); + } + + values.put(GameDatabase.KEY_GAME_TITLE, title); + values.put(GameDatabase.KEY_GAME_DESCRIPTION, description); + values.put(GameDatabase.KEY_GAME_REGIONS, regions); + values.put(GameDatabase.KEY_GAME_PATH, path); + values.put(GameDatabase.KEY_GAME_ID, gameId); + values.put(GameDatabase.KEY_GAME_COMPANY, company); + + return values; + } + + public static Game fromCursor(Cursor cursor) { + return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE), + cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION), + cursor.getString(GameDatabase.GAME_COLUMN_REGIONS), + cursor.getString(GameDatabase.GAME_COLUMN_PATH), + cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID), + cursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + } + + public String getTitle() { + return mTitle; + } + + public String getDescription() { + return mDescription; + } + + public String getCompany() { + return mCompany; + } + + public String getRegions() { + return mRegions; + } + + public String getPath() { + return mPath; + } + + public String getGameId() { + return mGameId; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java new file mode 100644 index 0000000000..8232d0489e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java @@ -0,0 +1,276 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.utils.Log; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import rx.Observable; + +/** + * A helper class that provides several utilities simplifying interaction with + * the SQLite database. + */ +public final class GameDatabase extends SQLiteOpenHelper { + public static final int COLUMN_DB_ID = 0; + public static final int GAME_COLUMN_PATH = 1; + public static final int GAME_COLUMN_TITLE = 2; + public static final int GAME_COLUMN_DESCRIPTION = 3; + public static final int GAME_COLUMN_REGIONS = 4; + public static final int GAME_COLUMN_GAME_ID = 5; + public static final int GAME_COLUMN_COMPANY = 6; + public static final int FOLDER_COLUMN_PATH = 1; + public static final String KEY_DB_ID = "_id"; + public static final String KEY_GAME_PATH = "path"; + public static final String KEY_GAME_TITLE = "title"; + public static final String KEY_GAME_DESCRIPTION = "description"; + public static final String KEY_GAME_REGIONS = "regions"; + public static final String KEY_GAME_ID = "game_id"; + public static final String KEY_GAME_COMPANY = "company"; + public static final String KEY_FOLDER_PATH = "path"; + public static final String TABLE_NAME_FOLDERS = "folders"; + public static final String TABLE_NAME_GAMES = "games"; + private static final int DB_VERSION = 2; + private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY"; + private static final String TYPE_INTEGER = " INTEGER"; + private static final String TYPE_STRING = " TEXT"; + + private static final String CONSTRAINT_UNIQUE = " UNIQUE"; + + private static final String SEPARATOR = ", "; + + private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_GAME_PATH + TYPE_STRING + SEPARATOR + + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR + + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR + + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR + + KEY_GAME_ID + TYPE_STRING + SEPARATOR + + KEY_GAME_COMPANY + TYPE_STRING + ")"; + + private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")"; + + private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; + private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; + + public GameDatabase(Context context) { + // Superclass constructor builds a database or uses an existing one. + super(context, "games.db", null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase database) { + Log.debug("[GameDatabase] GameDatabase - Creating database..."); + + execSqlAndLog(database, SQL_CREATE_GAMES); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + } + + @Override + public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases.."); + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + @Override + public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " + + newVersion); + + // Delete all the games + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void resetDatabase(SQLiteDatabase database) { + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void scanLibrary(SQLiteDatabase database) { + // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing. + Cursor fileCursor = database.query(TABLE_NAME_GAMES, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of games is irrelevant. + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + fileCursor.moveToPosition(-1); + + while (fileCursor.moveToNext()) { + String gamePath = fileCursor.getString(GAME_COLUMN_PATH); + File game = new File(gamePath); + + if (!game.exists()) { + Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " + + gamePath); + database.delete(TABLE_NAME_GAMES, + KEY_DB_ID + " = ?", + new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))}); + } + } + + // Get a cursor listing all the folders the user has added to the library. + Cursor folderCursor = database.query(TABLE_NAME_FOLDERS, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of folders is irrelevant. + + Set allowedExtensions = new HashSet(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + folderCursor.moveToPosition(-1); + + // Iterate through all results of the DB query (i.e. all folders in the library.) + while (folderCursor.moveToNext()) { + String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); + + File folder = new File(folderPath); + // If the folder is empty because it no longer exists, remove it from the library. + if (!folder.exists()) { + Log.error( + "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); + database.delete(TABLE_NAME_FOLDERS, + KEY_DB_ID + " = ?", + new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); + } + + addGamesRecursive(database, folder, allowedExtensions, 3); + } + + fileCursor.close(); + folderCursor.close(); + + database.close(); + } + + private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) { + if (depth <= 0) { + return; + } + + File[] children = parent.listFiles(); + if (children != null) { + for (File file : children) { + if (file.isHidden()) { + continue; + } + + if (file.isDirectory()) { + Set newExtensions = new HashSet<>(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + addGamesRecursive(database, file, newExtensions, depth - 1); + } else { + String filePath = file.getPath(); + + int extensionStart = filePath.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filePath.substring(extensionStart); + + // Check that the file has an extension we care about before trying to read out of it. + if (allowedExtensions.contains(fileExtension.toLowerCase())) { + attemptToAddGame(database, filePath); + } + } + } + } + } + } + + private static void attemptToAddGame(SQLiteDatabase database, String filePath) { + String name = NativeLibrary.GetTitle(filePath); + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = filePath.substring(filePath.lastIndexOf("/") + 1); + } + + String gameId = NativeLibrary.GetGameId(filePath); + + // If the game's ID field is empty, use the filename without extension. + if (gameId.isEmpty()) { + gameId = filePath.substring(filePath.lastIndexOf("/") + 1, + filePath.lastIndexOf(".")); + } + + ContentValues game = Game.asContentValues(name, + NativeLibrary.GetDescription(filePath).replace("\n", " "), + NativeLibrary.GetRegions(filePath), + filePath, + gameId, + NativeLibrary.GetCompany(filePath)); + + // Try to update an existing game first. + int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update. + game, + // The values to fill the row with. + KEY_GAME_ID + " = ?", + // The WHERE clause used to find the right row. + new String[]{game.getAsString( + KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this, + // which is provided as an array because there + // could potentially be more than one argument. + + // If update fails, insert a new game instead. + if (rowsMatched == 0) { + Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE)); + database.insert(TABLE_NAME_GAMES, null, game); + } else { + Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE)); + } + } + + public Observable getGames() { + return Observable.create(subscriber -> + { + Log.info("[GameDatabase] Reading games list..."); + + SQLiteDatabase database = getReadableDatabase(); + Cursor resultCursor = database.query( + TABLE_NAME_GAMES, + null, + null, + null, + null, + null, + KEY_GAME_TITLE + " ASC" + ); + + // Pass the result cursor to the consumer. + subscriber.onNext(resultCursor); + + // Tell the consumer we're done; it will unsubscribe implicitly. + subscriber.onCompleted(); + }); + } + + private void execSqlAndLog(SQLiteDatabase database, String sql) { + Log.verbose("[GameDatabase] Executing SQL: " + sql); + database.execSQL(sql); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java new file mode 100644 index 0000000000..33b289fc41 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java @@ -0,0 +1,138 @@ +package org.citra.citra_emu.model; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.BuildConfig; +import org.citra.citra_emu.utils.Log; + +/** + * Provides an interface allowing Activities to interact with the SQLite database. + * CRUD methods in this class can be called by Activities using getContentResolver(). + */ +public final class GameProvider extends ContentProvider { + public static final String REFRESH_LIBRARY = "refresh"; + public static final String RESET_LIBRARY = "reset"; + + public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider"; + public static final Uri URI_FOLDER = + Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/"); + public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/"); + public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/"); + + public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder"; + public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game"; + + + private GameDatabase mDbHelper; + + @Override + public boolean onCreate() { + Log.info("[GameProvider] Creating Content Provider..."); + + mDbHelper = new GameDatabase(getContext()); + + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + Log.info("[GameProvider] Querying URI: " + uri); + + SQLiteDatabase db = mDbHelper.getReadableDatabase(); + + String table = uri.getLastPathSegment(); + + if (table == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + Log.verbose("[GameProvider] Getting MIME type for URI: " + uri); + String lastSegment = uri.getLastPathSegment(); + + if (lastSegment == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + return MIME_TYPE_FOLDER; + } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) { + return MIME_TYPE_GAME; + } + + Log.error("[GameProvider] Unknown MIME type for URI: " + uri); + return null; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + Log.info("[GameProvider] Inserting row at URI: " + uri); + + SQLiteDatabase database = mDbHelper.getWritableDatabase(); + String table = uri.getLastPathSegment(); + + if (table != null) { + if (table.equals(RESET_LIBRARY)) { + mDbHelper.resetDatabase(database); + return uri; + } + if (table.equals(REFRESH_LIBRARY)) { + Log.info( + "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents..."); + mDbHelper.scanLibrary(database); + return uri; + } + + long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE); + + // If insertion was successful... + if (id > 0) { + // If we just added a folder, add its contents to the game list. + if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + mDbHelper.scanLibrary(database); + } + + // Notify the UI that its contents should be refreshed. + getContext().getContentResolver().notifyChange(uri, null); + uri = Uri.withAppendedPath(uri, Long.toString(id)); + } else { + Log.error("[GameProvider] Row already exists: " + uri + " id: " + id); + } + } else { + Log.error("[GameProvider] Badly formatted URI: " + uri); + } + + database.close(); + + return uri; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + Log.error("[GameProvider] Delete operations unsupported. URI: " + uri); + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + Log.error("[GameProvider] Update operations unsupported. URI: " + uri); + return 0; + } +} 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 new file mode 100644 index 0000000000..cdb2f76667 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java @@ -0,0 +1,878 @@ +/** + * 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); + } + + int pointerIndex = event.getActionIndex(); + + if (mPreferences.getBoolean("isTouchEnabled", true)) { + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + if (NativeLibrary.onTouchEvent(event.getX(pointerIndex), event.getY(pointerIndex), true)) { + mTouchscreenPointerId = event.getPointerId(pointerIndex); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (mTouchscreenPointerId == event.getPointerId(pointerIndex)) { + // We don't really care where the touch has been released. We only care whether it has been + // released or not. + NativeLibrary.onTouchEvent(0, 0, false); + mTouchscreenPointerId = -1; + } + break; + } + + for (int i = 0; i < event.getPointerCount(); i++) { + if (mTouchscreenPointerId == event.getPointerId(i)) { + NativeLibrary.onTouchMoved(event.getX(i), event.getY(i)); + } + } + } + + 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 a pointer enters the bounds of a button, press that button. + if (button.getBounds() + .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + button.setPressedState(true); + button.setTrackId(event.getPointerId(pointerIndex)); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), + ButtonState.PRESSED); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + // If a pointer ends, release the button it was pressing. + if (button.getTrackId() == event.getPointerId(pointerIndex)) { + button.setPressedState(false); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), + ButtonState.RELEASED); + } + 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 a pointer enters the bounds of a button, press that button. + if (dpad.getBounds() + .contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + dpad.setTrackId(event.getPointerId(pointerIndex)); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + // If a pointer ends, release the buttons. + if (dpad.getTrackId() == event.getPointerId(pointerIndex)) { + for (int i = 0; i < 4; i++) { + dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(i), + NativeLibrary.ButtonState.RELEASED); + } + dpad.setTrackId(-1); + } + break; + } + + if (dpad.getTrackId() != -1) { + for (int i = 0; i < event.getPointerCount(); i++) { + if (dpad.getTrackId() == event.getPointerId(i)) { + float touchX = event.getX(i); + float touchY = event.getY(i); + float maxY = dpad.getBounds().bottom; + float maxX = dpad.getBounds().right; + touchX -= dpad.getBounds().centerX(); + maxX -= dpad.getBounds().centerX(); + touchY -= dpad.getBounds().centerY(); + maxY -= dpad.getBounds().centerY(); + final float AxisX = touchX / maxX; + final float AxisY = touchY / maxY; + + boolean up = false; + boolean down = false; + boolean left = false; + boolean right = false; + if (EmulationMenuSettings.getDpadSlideEnable() || + (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_DOWN || + (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_POINTER_DOWN) { + if (AxisY < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0), + NativeLibrary.ButtonState.PRESSED); + up = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(0), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisY > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1), + NativeLibrary.ButtonState.PRESSED); + down = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(1), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisX < -InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2), + NativeLibrary.ButtonState.PRESSED); + left = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(2), + NativeLibrary.ButtonState.RELEASED); + } + if (AxisX > InputOverlayDrawableDpad.VIRT_AXIS_DEADZONE) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3), + NativeLibrary.ButtonState.PRESSED); + right = true; + } else { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getId(3), + NativeLibrary.ButtonState.RELEASED); + } + + // Set state + if (up) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP); + } else if (down) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN); + } else if (left) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT); + } else if (right) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT); + } else { + dpad.setState(InputOverlayDrawableDpad.STATE_DEFAULT); + } + } + } + } + } + } + + for (InputOverlayDrawableJoystick joystick : overlayJoysticks) { + joystick.TrackEvent(event); + int axisID = joystick.getId(); + float[] axises = joystick.getAxisValues(); + + NativeLibrary + .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, axises[0], axises[1]); + } + + invalidate(); + + return true; + } + + 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.getId(0), + 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.getId(), + mJoystickBeingConfigured.getBounds().left, + mJoystickBeingConfigured.getBounds().top, orientation); + mJoystickBeingConfigured = null; + } + break; + } + } + + return true; + } + + private void setDpadState(InputOverlayDrawableDpad dpad, boolean up, boolean down, boolean left, + boolean right) { + if (up) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_UP); + } else if (down) { + if (left) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_LEFT); + else if (right) + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN_RIGHT); + else + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_DOWN); + } else if (left) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_LEFT); + } else if (right) { + dpad.setState(InputOverlayDrawableDpad.STATE_PRESSED_RIGHT); + } + } + + 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.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/InputOverlayDrawableButton.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java new file mode 100644 index 0000000000..81352296c9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java @@ -0,0 +1,122 @@ +/** + * 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; + +/** + * 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; + + mWidth = mDefaultStateBitmap.getIntrinsicWidth(); + mHeight = mDefaultStateBitmap.getIntrinsicHeight(); + } + + /** + * Gets this InputOverlayDrawableButton's button ID. + * + * @return this InputOverlayDrawableButton's button ID. + */ + public int getId() { + return mButtonType; + } + + public int getTrackId() { + return mTrackId; + } + + public void setTrackId(int trackId) { + mTrackId = trackId; + } + + 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 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/InputOverlayDrawableDpad.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java new file mode 100644 index 0000000000..87f3b7cd96 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java @@ -0,0 +1,193 @@ +/** + * 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; + +/** + * Custom {@link BitmapDrawable} that is capable + * of storing it's own ID. + */ +public final class InputOverlayDrawableDpad { + public static final int STATE_DEFAULT = 0; + public static final int STATE_PRESSED_UP = 1; + public static final int STATE_PRESSED_DOWN = 2; + public static final int STATE_PRESSED_LEFT = 3; + public static final int STATE_PRESSED_RIGHT = 4; + public static final int STATE_PRESSED_UP_LEFT = 5; + public static final int STATE_PRESSED_UP_RIGHT = 6; + public static final int STATE_PRESSED_DOWN_LEFT = 7; + public static final int STATE_PRESSED_DOWN_RIGHT = 8; + public static final float VIRT_AXIS_DEADZONE = 0.5f; + // The ID identifying what type of button this Drawable represents. + private int[] mButtonType = new int[4]; + 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 int mPressState = STATE_DEFAULT; + + /** + * 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(); + + mButtonType[0] = buttonUp; + mButtonType[1] = buttonDown; + mButtonType[2] = buttonLeft; + mButtonType[3] = buttonRight; + + mTrackId = -1; + } + + public void draw(Canvas canvas) { + int px = mControlPositionX + (getWidth() / 2); + int py = mControlPositionY + (getHeight() / 2); + switch (mPressState) { + case STATE_DEFAULT: + mDefaultStateBitmap.draw(canvas); + break; + case STATE_PRESSED_UP: + mPressedOneDirectionStateBitmap.draw(canvas); + break; + case STATE_PRESSED_RIGHT: + canvas.save(); + canvas.rotate(90, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN: + canvas.save(); + canvas.rotate(180, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_LEFT: + canvas.save(); + canvas.rotate(270, px, py); + mPressedOneDirectionStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_UP_LEFT: + mPressedTwoDirectionsStateBitmap.draw(canvas); + break; + case STATE_PRESSED_UP_RIGHT: + canvas.save(); + canvas.rotate(90, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN_RIGHT: + canvas.save(); + canvas.rotate(180, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + case STATE_PRESSED_DOWN_LEFT: + canvas.save(); + canvas.rotate(270, px, py); + mPressedTwoDirectionsStateBitmap.draw(canvas); + canvas.restore(); + break; + } + } + + /** + * Gets one of the InputOverlayDrawableDpad's button IDs. + * + * @return the requested InputOverlayDrawableDpad's button ID. + */ + public int getId(int direction) { + return mButtonType[direction]; + } + + public int getTrackId() { + return mTrackId; + } + + public void setTrackId(int trackId) { + mTrackId = trackId; + } + + 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; + } + + public void setState(int pressState) { + mPressState = pressState; + } +} 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 new file mode 100644 index 0000000000..956a8b1e90 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java @@ -0,0 +1,264 @@ +/** + * 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 { + private final int[] axisIDs = {0, 0, 0, 0}; + private final float[] axises = {0f, 0f}; + private int trackId = -1; + private int mJoystickType; + 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) { + axisIDs[0] = joystick + 1; // Up + axisIDs[1] = joystick + 2; // Down + axisIDs[2] = joystick + 3; // Left + axisIDs[3] = joystick + 4; // Right + mJoystickType = 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(); + } + + /** + * Gets this InputOverlayDrawableJoystick's button ID. + * + * @return this InputOverlayDrawableJoystick's button ID. + */ + public int getId() { + return mJoystickType; + } + + public void draw(Canvas canvas) { + mOuterBitmap.draw(canvas); + getCurrentStateBitmapDrawable().draw(canvas); + mBoundsBoxBitmap.draw(canvas); + } + + public void TrackEvent(MotionEvent event) { + int pointerIndex = event.getActionIndex(); + + switch (event.getAction() & MotionEvent.ACTION_MASK) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_POINTER_DOWN: + if (getBounds().contains((int) event.getX(pointerIndex), (int) event.getY(pointerIndex))) { + mPressedState = true; + mOuterBitmap.setAlpha(0); + mBoundsBoxBitmap.setAlpha(255); + if (EmulationMenuSettings.getJoystickRelCenter()) { + getVirtBounds().offset((int) event.getX(pointerIndex) - getVirtBounds().centerX(), + (int) event.getY(pointerIndex) - getVirtBounds().centerY()); + } + mBoundsBoxBitmap.setBounds(getVirtBounds()); + trackId = event.getPointerId(pointerIndex); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_POINTER_UP: + if (trackId == event.getPointerId(pointerIndex)) { + mPressedState = false; + axises[0] = axises[1] = 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(); + trackId = -1; + } + break; + } + + if (trackId == -1) + return; + + for (int i = 0; i < event.getPointerCount(); i++) { + if (trackId == event.getPointerId(i)) { + 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; + + // 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; + } + axises[0] = ((float)Math.cos(angle) * radius); + axises[1] = ((float)Math.sin(angle) * radius); + SetInnerBounds(); + } + } + } + + 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 (mJoystickType == 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 float[] getAxisValues() { + return axises; + } + + public int[] getAxisIDs() { + return axisIDs; + } + + private void SetInnerBounds() { + int X = getVirtBounds().centerX() + (int) ((axises[0]) * (getVirtBounds().width() / 2)); + int Y = getVirtBounds().centerY() + (int) ((axises[1]) * (getVirtBounds().height() / 2)); + + if (mJoystickType == ButtonType.STICK_LEFT) { + X += 1; + Y += 1; + } + + 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/ui/DividerItemDecoration.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java new file mode 100644 index 0000000000..96ccc08bb6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000000..d07fe30d89 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java @@ -0,0 +1,37 @@ +package org.citra.citra_emu.ui; + +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.slidingpanelayout.widget.SlidingPaneLayout; + +public class TwoPaneOnBackPressedCallback extends OnBackPressedCallback + implements SlidingPaneLayout.PanelSlideListener { + private final SlidingPaneLayout mSlidingPaneLayout; + + public TwoPaneOnBackPressedCallback(@NonNull SlidingPaneLayout slidingPaneLayout) { + super(slidingPaneLayout.isSlideable() && slidingPaneLayout.isOpen()); + mSlidingPaneLayout = slidingPaneLayout; + slidingPaneLayout.addPanelSlideListener(this); + } + + @Override + public void handleOnBackPressed() { + mSlidingPaneLayout.close(); + } + + @Override + public void onPanelSlide(@NonNull View panel, float slideOffset) { + } + + @Override + public void onPanelOpened(@NonNull View panel) { + setEnabled(true); + } + + @Override + public void onPanelClosed(@NonNull View panel) { + setEnabled(false); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java new file mode 100644 index 0000000000..4ba419a489 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -0,0 +1,267 @@ +package org.citra.citra_emu.ui.main; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.features.settings.ui.SettingsActivity; +import org.citra.citra_emu.model.GameProvider; +import org.citra.citra_emu.ui.platform.PlatformGamesFragment; +import org.citra.citra_emu.utils.AddDirectoryHelper; +import org.citra.citra_emu.utils.BillingManager; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.FileBrowserHelper; +import org.citra.citra_emu.utils.PermissionsHandler; +import org.citra.citra_emu.utils.PicassoUtils; +import org.citra.citra_emu.utils.StartupHandler; +import org.citra.citra_emu.utils.ThemeUtil; + +import java.util.Arrays; +import java.util.Collections; + +/** + * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which + * individually display a grid of available games for each Fragment, in a tabbed layout. + */ +public final class MainActivity extends AppCompatActivity implements MainView { + private Toolbar mToolbar; + private int mFrameLayoutId; + private PlatformGamesFragment mPlatformGamesFragment; + + private MainPresenter mPresenter = new MainPresenter(this); + + // Singleton to manage user billing state + private static BillingManager mBillingManager; + + private static MenuItem mPremiumButton; + + @Override + protected void onCreate(Bundle savedInstanceState) { + ThemeUtil.applyTheme(); + + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + findViews(); + + setSupportActionBar(mToolbar); + + mFrameLayoutId = R.id.games_platform_frame; + mPresenter.onCreate(); + + if (savedInstanceState == null) { + StartupHandler.HandleInit(this); + if (PermissionsHandler.hasWriteAccess(this)) { + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) + .commit(); + } + } else { + mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment"); + } + PicassoUtils.init(); + + // Setup billing manager, so we can globally query for Premium status + mBillingManager = new BillingManager(this); + + // Dismiss previous notifications (should not happen unless a crash occurred) + EmulationActivity.tryDismissRunningNotification(this); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (PermissionsHandler.hasWriteAccess(this)) { + if (getSupportFragmentManager() == null) { + return; + } + if (outState == null) { + return; + } + getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment); + } + } + + @Override + protected void onResume() { + super.onResume(); + mPresenter.addDirIfNeeded(new AddDirectoryHelper(this)); + } + + // TODO: Replace with a ButterKnife injection. + private void findViews() { + mToolbar = findViewById(R.id.toolbar_main); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_game_grid, menu); + mPremiumButton = menu.findItem(R.id.button_premium); + + if (mBillingManager.isPremiumCached()) { + // User had premium in a previous session, hide upsell option + setPremiumButtonVisible(false); + } + + return true; + } + + static public void setPremiumButtonVisible(boolean isVisible) { + if (mPremiumButton != null) { + mPremiumButton.setVisible(isVisible); + } + } + + /** + * MainView + */ + + @Override + public void setVersionString(String version) { + mToolbar.setSubtitle(version); + } + + @Override + public void refresh() { + getContentResolver().insert(GameProvider.URI_REFRESH, null); + refreshFragment(); + } + + @Override + public void launchSettingsActivity(String menuTag) { + if (PermissionsHandler.hasWriteAccess(this)) { + SettingsActivity.launch(this, menuTag, ""); + } else { + PermissionsHandler.checkWritePermission(this); + } + } + + @Override + public void launchFileListActivity(int request) { + if (PermissionsHandler.hasWriteAccess(this)) { + switch (request) { + case MainPresenter.REQUEST_ADD_DIRECTORY: + FileBrowserHelper.openDirectoryPicker(this, + MainPresenter.REQUEST_ADD_DIRECTORY, + R.string.select_game_folder, + Arrays.asList("xci", "nsp", "cci", "3ds", + "cxi", "app", "3dsx", "cia", + "rar", "zip", "7z", "torrent", + "tar", "gz", "nro")); + break; + case MainPresenter.REQUEST_INSTALL_CIA: + FileBrowserHelper.openFilePicker(this, MainPresenter.REQUEST_INSTALL_CIA, + R.string.install_cia_title, + Collections.singletonList("cia"), true); + break; + } + } else { + PermissionsHandler.checkWritePermission(this); + } + } + + /** + * @param requestCode An int describing whether the Activity that is returning did so successfully. + * @param resultCode An int describing what Activity is giving us this callback. + * @param result The information the returning Activity is providing us. + */ + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent result) { + super.onActivityResult(requestCode, resultCode, result); + switch (requestCode) { + case MainPresenter.REQUEST_ADD_DIRECTORY: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + // TODO(bunnei): Consider fixing this in the future, or removing code for this. + getContentResolver().insert(GameProvider.URI_RESET, null); + // Add the new directory + mPresenter.onDirectorySelected(FileBrowserHelper.getSelectedDirectory(result)); + } + break; + case MainPresenter.REQUEST_INSTALL_CIA: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + mPresenter.refeshGameList(); + } + break; + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case PermissionsHandler.REQUEST_CODE_WRITE_PERMISSION: + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + DirectoryInitialization.start(this); + + mPlatformGamesFragment = new PlatformGamesFragment(); + getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) + .commit(); + + // Immediately prompt user to select a game directory on first boot + if (mPresenter != null) { + mPresenter.launchFileListActivity(MainPresenter.REQUEST_ADD_DIRECTORY); + } + } else { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + break; + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + break; + } + } + + /** + * Called by the framework whenever any actionbar/toolbar icon is clicked. + * + * @param item The icon that was clicked on. + * @return True if the event was handled, false to bubble it up to the OS. + */ + @Override + public boolean onOptionsItemSelected(MenuItem item) { + return mPresenter.handleOptionSelection(item.getItemId()); + } + + private void refreshFragment() { + if (mPlatformGamesFragment != null) { + mPlatformGamesFragment.refresh(); + } + } + + @Override + protected void onDestroy() { + EmulationActivity.tryDismissRunningNotification(this); + super.onDestroy(); + } + + /** + * @return true if Premium subscription is currently active + */ + public static boolean isPremiumActive() { + return mBillingManager.isPremiumActive(); + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public static void invokePremiumBilling(Runnable callback) { + mBillingManager.invokePremiumBilling(callback); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java new file mode 100644 index 0000000000..4e9994c2a4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java @@ -0,0 +1,82 @@ +package org.citra.citra_emu.ui.main; + +import android.os.SystemClock; + +import org.citra.citra_emu.BuildConfig; +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.AddDirectoryHelper; + +public final class MainPresenter { + public static final int REQUEST_ADD_DIRECTORY = 1; + public static final int REQUEST_INSTALL_CIA = 2; + + private final MainView mView; + private String mDirToAdd; + private long mLastClickTime = 0; + + public MainPresenter(MainView view) { + mView = view; + } + + public void onCreate() { + String versionName = BuildConfig.VERSION_NAME; + mView.setVersionString(versionName); + refeshGameList(); + } + + public void launchFileListActivity(int request) { + if (mView != null) { + mView.launchFileListActivity(request); + } + } + + public boolean handleOptionSelection(int itemId) { + // Double-click prevention, using threshold of 500 ms + if (SystemClock.elapsedRealtime() - mLastClickTime < 500) { + return false; + } + mLastClickTime = SystemClock.elapsedRealtime(); + + switch (itemId) { + case R.id.menu_settings_core: + mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG); + return true; + + case R.id.button_add_directory: + launchFileListActivity(REQUEST_ADD_DIRECTORY); + return true; + + case R.id.button_install_cia: + launchFileListActivity(REQUEST_INSTALL_CIA); + return true; + + case R.id.button_premium: + mView.launchSettingsActivity(Settings.SECTION_PREMIUM); + return true; + } + + return false; + } + + public void addDirIfNeeded(AddDirectoryHelper helper) { + if (mDirToAdd != null) { + helper.addDirectory(mDirToAdd, mView::refresh); + + mDirToAdd = null; + } + } + + public void onDirectorySelected(String dir) { + mDirToAdd = dir; + } + + public void refeshGameList() { + GameDatabase databaseHelper = CitraApplication.databaseHelper; + databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); + mView.refresh(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java new file mode 100644 index 0000000000..de7c048759 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java @@ -0,0 +1,25 @@ +package org.citra.citra_emu.ui.main; + +/** + * Abstraction for the screen that shows on application launch. + * Implementations will differ primarily to target touch-screen + * or non-touch screen devices. + */ +public interface MainView { + /** + * Pass the view the native library's version string. Displaying + * it is optional. + * + * @param version A string pulled from native code. + */ + void setVersionString(String version); + + /** + * Tell the view to refresh its contents. + */ + void refresh(); + + void launchSettingsActivity(String menuTag); + + void launchFileListActivity(int request); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java new file mode 100644 index 0000000000..9fc30796f1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java @@ -0,0 +1,86 @@ +package org.citra.citra_emu.ui.platform; + +import android.database.Cursor; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.adapters.GameAdapter; +import org.citra.citra_emu.model.GameDatabase; + +public final class PlatformGamesFragment extends Fragment implements PlatformGamesView { + private PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this); + + private GameAdapter mAdapter; + private RecyclerView mRecyclerView; + private TextView mTextView; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_grid, container, false); + + findViews(rootView); + + mPresenter.onCreateView(); + + return rootView; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState) { + int columns = getResources().getInteger(R.integer.game_grid_columns); + RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns); + mAdapter = new GameAdapter(); + + mRecyclerView.setLayoutManager(layoutManager); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.addItemDecoration(new GameAdapter.SpacesItemDecoration(ContextCompat.getDrawable(getActivity(), R.drawable.gamelist_divider), 1)); + + // Add swipe down to refresh gesture + final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.refresh_grid_games); + pullToRefresh.setOnRefreshListener(() -> { + GameDatabase databaseHelper = CitraApplication.databaseHelper; + databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); + refresh(); + pullToRefresh.setRefreshing(false); + }); + } + + @Override + public void refresh() { + mPresenter.refresh(); + updateTextView(); + } + + @Override + public void showGames(Cursor games) { + if (mAdapter != null) { + mAdapter.swapCursor(games); + } + updateTextView(); + } + + private void updateTextView() { + mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); + } + + private void findViews(View root) { + mRecyclerView = root.findViewById(R.id.grid_games); + mTextView = root.findViewById(R.id.gamelist_empty_text); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java new file mode 100644 index 0000000000..9d8040e1be --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java @@ -0,0 +1,42 @@ +package org.citra.citra_emu.ui.platform; + + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.Log; + +import rx.android.schedulers.AndroidSchedulers; +import rx.schedulers.Schedulers; + +public final class PlatformGamesPresenter { + private final PlatformGamesView mView; + + public PlatformGamesPresenter(PlatformGamesView view) { + mView = view; + } + + public void onCreateView() { + loadGames(); + } + + public void refresh() { + Log.debug("[PlatformGamesPresenter] : Refreshing..."); + loadGames(); + } + + private void loadGames() { + Log.debug("[PlatformGamesPresenter] : Loading games..."); + + GameDatabase databaseHelper = CitraApplication.databaseHelper; + + databaseHelper.getGames() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(games -> + { + Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor..."); + + mView.showGames(games); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java new file mode 100644 index 0000000000..4332121eb8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java @@ -0,0 +1,21 @@ +package org.citra.citra_emu.ui.platform; + +import android.database.Cursor; + +/** + * Abstraction for a screen representing a single platform's games. + */ +public interface PlatformGamesView { + /** + * Tell the view to refresh its contents. + */ + void refresh(); + + /** + * To be called when an asynchronous database read completes. Passes the + * result, in this case a {@link Cursor}, to the view. + * + * @param games A Cursor containing the games read from the database. + */ + void showGames(Cursor games); +} 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 new file mode 100644 index 0000000000..886846ec57 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java @@ -0,0 +1,5 @@ +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/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java new file mode 100644 index 0000000000..7578c353f8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java @@ -0,0 +1,38 @@ +package org.citra.citra_emu.utils; + +import android.content.AsyncQueryHandler; +import android.content.ContentValues; +import android.content.Context; +import android.net.Uri; + +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.model.GameProvider; + +public class AddDirectoryHelper { + private Context mContext; + + public AddDirectoryHelper(Context context) { + this.mContext = context; + } + + public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) { + AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) { + @Override + protected void onInsertComplete(int token, Object cookie, Uri uri) { + addDirectoryListener.onDirectoryAdded(); + } + }; + + ContentValues file = new ContentValues(); + file.put(GameDatabase.KEY_FOLDER_PATH, dir); + + handler.startInsert(0, // We don't need to identify this call to the handler + null, // We don't need to pass additional data to the handler + GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder + file); + } + + public interface AddDirectoryListener { + void onDirectoryAdded(); + } +} 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 new file mode 100644 index 0000000000..dfbab17804 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java @@ -0,0 +1,22 @@ +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/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java new file mode 100644 index 0000000000..5dc54c235e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java @@ -0,0 +1,215 @@ +package org.citra.citra_emu.utils; + +import android.app.Activity; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.widget.Toast; + +import com.android.billingclient.api.AcknowledgePurchaseParams; +import com.android.billingclient.api.AcknowledgePurchaseResponseListener; +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.ui.main.MainActivity; + +import java.util.ArrayList; +import java.util.List; + +public class BillingManager implements PurchasesUpdatedListener { + private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium"; + + private final Activity mActivity; + private BillingClient mBillingClient; + private SkuDetails mSkuPremium; + private boolean mIsPremiumActive = false; + private boolean mIsServiceConnected = false; + private Runnable mUpdateBillingCallback; + + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + public BillingManager(Activity activity) { + mActivity = activity; + mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build(); + querySkuDetails(); + } + + static public boolean isPremiumCached() { + return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false); + } + + /** + * @return true if Premium subscription is currently active + */ + public boolean isPremiumActive() { + return mIsPremiumActive; + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public void invokePremiumBilling(Runnable callback) { + if (mSkuPremium == null) { + return; + } + + // Optional callback to refresh the UI for the caller when billing completes + mUpdateBillingCallback = callback; + + // Invoke the billing flow + BillingFlowParams flowParams = BillingFlowParams.newBuilder() + .setSkuDetails(mSkuPremium) + .build(); + mBillingClient.launchBillingFlow(mActivity, flowParams); + } + + private void updatePremiumState(boolean isPremiumActive) { + mIsPremiumActive = isPremiumActive; + + // Cache state for synchronous UI + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive); + editor.apply(); + + // No need to show button in action bar if Premium is active + MainActivity.setPremiumButtonVisible(!isPremiumActive); + } + + @Override + public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) { + if (purchaseList == null || purchaseList.isEmpty()) { + // Premium is not active, or billing is unavailable + updatePremiumState(false); + return; + } + + Purchase premiumPurchase = null; + for (Purchase purchase : purchaseList) { + if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) { + premiumPurchase = purchase; + } + } + + if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { + // Premium has been purchased + updatePremiumState(true); + + // Acknowledge the purchase if it hasn't already been acknowledged. + if (!premiumPurchase.isAcknowledged()) { + AcknowledgePurchaseParams acknowledgePurchaseParams = + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(premiumPurchase.getPurchaseToken()) + .build(); + + AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> { + Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show(); + }; + mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener); + } + + if (mUpdateBillingCallback != null) { + try { + mUpdateBillingCallback.run(); + } catch (Exception e) { + e.printStackTrace(); + } + mUpdateBillingCallback = null; + } + } + } + + private void onQuerySkuDetailsFinished(List skuDetailsList) { + if (skuDetailsList == null) { + // This can happen when no user is signed in + return; + } + + if (skuDetailsList.isEmpty()) { + return; + } + + mSkuPremium = skuDetailsList.get(0); + + queryPurchases(); + } + + private void querySkuDetails() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); + List skuList = new ArrayList<>(); + + skuList.add(BILLING_SKU_PREMIUM); + params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP); + + mBillingClient.querySkuDetailsAsync(params.build(), + (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList)); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void onQueryPurchasesFinished(PurchasesResult result) { + // Have we been disposed of in the meantime? If so, or bad result code, then quit + if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) { + updatePremiumState(false); + return; + } + // Update the UI and purchases inventory with new list of purchases + onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList()); + } + + private void queryPurchases() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP); + onQueryPurchasesFinished(purchasesResult); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void startServiceConnection(final Runnable executeOnFinish) { + mBillingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(BillingResult billingResult) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + mIsServiceConnected = true; + } + + if (executeOnFinish != null) { + executeOnFinish.run(); + } + } + + @Override + public void onBillingServiceDisconnected() { + mIsServiceConnected = false; + } + }); + } + + private void executeServiceRequest(Runnable runnable) { + if (mIsServiceConnected) { + runnable.run(); + } else { + // If billing service was disconnected, we try to reconnect 1 time. + startServiceConnection(runnable); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java new file mode 100644 index 0000000000..f801a05f0f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java @@ -0,0 +1,66 @@ +package org.citra.citra_emu.utils; + +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Some controllers have incorrect mappings. This class has special-case fixes for them. + */ +public class ControllerMappingHelper { + /** + * Some controllers report extra button presses that can be ignored. + */ + public boolean shouldKeyBeIgnored(InputDevice inputDevice, int keyCode) { + if (isDualShock4(inputDevice)) { + // The two analog triggers generate analog motion events as well as a keycode. + // We always prefer to use the analog values, so throw away the button press + return keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2; + } + return false; + } + + /** + * Scale an axis to be zero-centered with a proper range. + */ + public float scaleAxis(InputDevice inputDevice, int axis, float value) { + if (isDualShock4(inputDevice)) { + // Android doesn't have correct mappings for this controller's triggers. It reports them + // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0] + // Scale them to properly zero-centered with a range of [0.0, 1.0]. + if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) { + return (value + 1) / 2.0f; + } + } else if (isXboxOneWireless(inputDevice)) { + // Same as the DualShock 4, the mappings are missing. + if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) { + return (value + 1) / 2.0f; + } + if (axis == MotionEvent.AXIS_GENERIC_1) { + // This axis is stuck at ~.5. Ignore it. + return 0.0f; + } + } else if (isMogaPro2Hid(inputDevice)) { + // This controller has a broken axis that reports a constant value. Ignore it. + if (axis == MotionEvent.AXIS_GENERIC_1) { + return 0.0f; + } + } + return value; + } + + private boolean isDualShock4(InputDevice inputDevice) { + // Sony DualShock 4 controller + return inputDevice.getVendorId() == 0x54c && inputDevice.getProductId() == 0x9cc; + } + + private boolean isXboxOneWireless(InputDevice inputDevice) { + // Microsoft Xbox One controller + return inputDevice.getVendorId() == 0x45e && inputDevice.getProductId() == 0x2e0; + } + + private boolean isMogaPro2Hid(InputDevice inputDevice) { + // Moga Pro 2 HID + return inputDevice.getVendorId() == 0x20d6 && inputDevice.getProductId() == 0x6271; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java new file mode 100644 index 0000000000..58e552f5eb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java @@ -0,0 +1,186 @@ +/** + * Copyright 2014 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.utils; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Environment; +import android.preference.PreferenceManager; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A service that spawns its own thread in order to copy several binary and shader files + * from the Citra APK to the external file system. + */ +public final class DirectoryInitialization { + public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST"; + + public static final String EXTRA_STATE = "directoryState"; + private static volatile DirectoryInitializationState directoryState = null; + private static String userPath; + private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false); + + public static void start(Context context) { + // Can take a few seconds to run, so don't block UI thread. + //noinspection TrivialFunctionalExpressionUsage + ((Runnable) () -> init(context)).run(); + } + + private static void init(Context context) { + if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true)) + return; + + if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + if (PermissionsHandler.hasWriteAccess(context)) { + if (setCitraUserDirectory()) { + initializeInternalStorage(context); + NativeLibrary.CreateConfigFile(); + directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; + } else { + directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE; + } + } else { + directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED; + } + } + + isCitraDirectoryInitializationRunning.set(false); + sendBroadcastState(directoryState, context); + } + + private static void deleteDirectoryRecursively(File file) { + if (file.isDirectory()) { + for (File child : file.listFiles()) + deleteDirectoryRecursively(child); + } + file.delete(); + } + + public static boolean areCitraDirectoriesReady() { + return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; + } + + public static String getUserDirectory() { + if (directoryState == null) { + throw new IllegalStateException("DirectoryInitialization has to run at least once!"); + } else if (isCitraDirectoryInitializationRunning.get()) { + throw new IllegalStateException( + "DirectoryInitialization has to finish running first!"); + } + return userPath; + } + + private static native void SetSysDirectory(String path); + + private static boolean setCitraUserDirectory() { + if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) { + File externalPath = Environment.getExternalStorageDirectory(); + if (externalPath != null) { + userPath = externalPath.getAbsolutePath() + "/citra-emu"; + Log.debug("[DirectoryInitialization] User Dir: " + userPath); + // NativeLibrary.SetUserDirectory(userPath); + return true; + } + + } + + return false; + } + + private static void initializeInternalStorage(Context context) { + File sysDirectory = new File(context.getFilesDir(), "Sys"); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String revision = NativeLibrary.GetGitRevision(); + if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) { + // There is no extracted Sys directory, or there is a Sys directory from another + // version of Citra that might contain outdated files. Let's (re-)extract Sys. + deleteDirectoryRecursively(sysDirectory); + copyAssetFolder("Sys", sysDirectory, true, context); + + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("sysDirectoryVersion", revision); + editor.apply(); + } + + // Let the native code know where the Sys directory is. + SetSysDirectory(sysDirectory.getPath()); + } + + private static void sendBroadcastState(DirectoryInitializationState state, Context context) { + Intent localIntent = + new Intent(BROADCAST_ACTION) + .putExtra(EXTRA_STATE, state); + LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent); + } + + private static void copyAsset(String asset, File output, Boolean overwrite, Context context) { + Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output); + + try { + if (!output.exists() || overwrite) { + InputStream in = context.getAssets().open(asset); + OutputStream out = new FileOutputStream(output); + copyFile(in, out); + in.close(); + out.close(); + } + } catch (IOException e) { + Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset + + e.getMessage()); + } + } + + private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite, + Context context) { + Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " + + outputFolder); + + try { + boolean createdFolder = false; + for (String file : context.getAssets().list(assetFolder)) { + if (!createdFolder) { + outputFolder.mkdir(); + createdFolder = true; + } + copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file), + overwrite, context); + copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite, + context); + } + } catch (IOException e) { + Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder + + e.getMessage()); + } + } + + private static void copyFile(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[1024]; + int read; + + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + + public enum DirectoryInitializationState { + CITRA_DIRECTORIES_INITIALIZED, + EXTERNAL_STORAGE_PERMISSION_NEEDED, + CANT_FIND_EXTERNAL_STORAGE + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java new file mode 100644 index 0000000000..5d1e951ca1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java @@ -0,0 +1,22 @@ +package org.citra.citra_emu.utils; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; + +public class DirectoryStateReceiver extends BroadcastReceiver { + Action1 callback; + + public DirectoryStateReceiver(Action1 callback) { + this.callback = callback; + } + + @Override + public void onReceive(Context context, Intent intent) { + DirectoryInitializationState state = (DirectoryInitializationState) intent + .getSerializableExtra(DirectoryInitialization.EXTRA_STATE); + callback.call(state); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java new file mode 100644 index 0000000000..9664f84647 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java @@ -0,0 +1,78 @@ +package org.citra.citra_emu.utils; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.citra.citra_emu.CitraApplication; + +public class EmulationMenuSettings { + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + // These must match what is defined in src/core/settings.h + public static final int LayoutOption_Default = 0; + public static final int LayoutOption_SingleScreen = 1; + public static final int LayoutOption_LargeScreen = 2; + public static final int LayoutOption_SideScreen = 3; + public static final int LayoutOption_MobilePortrait = 4; + public static final int LayoutOption_MobileLandscape = 5; + + public static boolean getJoystickRelCenter() { + return mPreferences.getBoolean("EmulationMenuSettings_JoystickRelCenter", true); + } + + public static void setJoystickRelCenter(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_JoystickRelCenter", value); + editor.apply(); + } + + public static boolean getDpadSlideEnable() { + return mPreferences.getBoolean("EmulationMenuSettings_DpadSlideEnable", true); + } + + public static void setDpadSlideEnable(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_DpadSlideEnable", value); + editor.apply(); + } + + public static int getLandscapeScreenLayout() { + return mPreferences.getInt("EmulationMenuSettings_LandscapeScreenLayout", LayoutOption_MobileLandscape); + } + + public static void setLandscapeScreenLayout(int value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("EmulationMenuSettings_LandscapeScreenLayout", value); + editor.apply(); + } + + public static boolean getShowFps() { + return mPreferences.getBoolean("EmulationMenuSettings_ShowFps", false); + } + + public static void setShowFps(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_ShowFps", value); + editor.apply(); + } + + public static boolean getSwapScreens() { + return mPreferences.getBoolean("EmulationMenuSettings_SwapScreens", false); + } + + public static void setSwapScreens(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_SwapScreens", value); + editor.apply(); + } + + public static boolean getShowOverlay() { + return mPreferences.getBoolean("EmulationMenuSettings_ShowOverylay", true); + } + + public static void setShowOverlay(boolean value) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putBoolean("EmulationMenuSettings_ShowOverylay", value); + editor.apply(); + } +} 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 new file mode 100644 index 0000000000..baf691f5c1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java @@ -0,0 +1,73 @@ +package org.citra.citra_emu.utils; + +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; + +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; + +import com.nononsenseapps.filepicker.FilePickerActivity; +import com.nononsenseapps.filepicker.Utils; + +import org.citra.citra_emu.activities.CustomFilePickerActivity; + +import java.io.File; +import java.util.List; + +public final class FileBrowserHelper { + public static void openDirectoryPicker(FragmentActivity activity, int requestCode, int title, List extensions) { + Intent i = new Intent(activity, CustomFilePickerActivity.class); + + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, false); + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_DIR); + i.putExtra(FilePickerActivity.EXTRA_START_PATH, + Environment.getExternalStorageDirectory().getPath()); + i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); + i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); + + activity.startActivityForResult(i, requestCode); + } + + public static void openFilePicker(FragmentActivity activity, int requestCode, int title, + List extensions, boolean allowMultiple) { + Intent i = new Intent(activity, CustomFilePickerActivity.class); + + i.putExtra(FilePickerActivity.EXTRA_ALLOW_MULTIPLE, allowMultiple); + i.putExtra(FilePickerActivity.EXTRA_ALLOW_CREATE_DIR, false); + i.putExtra(FilePickerActivity.EXTRA_MODE, FilePickerActivity.MODE_FILE); + i.putExtra(FilePickerActivity.EXTRA_START_PATH, + Environment.getExternalStorageDirectory().getPath()); + i.putExtra(CustomFilePickerActivity.EXTRA_TITLE, title); + i.putExtra(CustomFilePickerActivity.EXTRA_EXTENSIONS, String.join(",", extensions)); + + activity.startActivityForResult(i, requestCode); + } + + @Nullable + public static String getSelectedDirectory(Intent result) { + // Use the provided utility method to parse the result + List files = Utils.getSelectedFilesFromResult(result); + if (!files.isEmpty()) { + File file = Utils.getFileForUri(files.get(0)); + return file.getAbsolutePath(); + } + + return null; + } + + @Nullable + public static String[] getSelectedFiles(Intent result) { + // Use the provided utility method to parse the result + List files = Utils.getSelectedFilesFromResult(result); + if (!files.isEmpty()) { + String[] paths = new String[files.size()]; + for (int i = 0; i < files.size(); i++) + paths[i] = Utils.getFileForUri(files.get(i)).getAbsolutePath(); + return paths; + } + + return null; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java new file mode 100644 index 0000000000..f9025171b7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java @@ -0,0 +1,37 @@ +package org.citra.citra_emu.utils; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +public class FileUtil { + public static byte[] getBytesFromFile(File file) throws IOException { + final long length = file.length(); + + // You cannot create an array using a long type. + if (length > Integer.MAX_VALUE) { + // File is too large + throw new IOException("File is too large!"); + } + + byte[] bytes = new byte[(int) length]; + + int offset = 0; + int numRead; + + try (InputStream is = new FileInputStream(file)) { + while (offset < bytes.length + && (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { + offset += numRead; + } + } + + // Ensure all the bytes have been read in + if (offset < bytes.length) { + throw new IOException("Could not completely read file " + file.getName()); + } + + return bytes; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java new file mode 100644 index 0000000000..31c4157793 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java @@ -0,0 +1,63 @@ +/** + * Copyright 2014 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.utils; + +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +/** + * A service that shows a permanent notification in the background to avoid the app getting + * cleared from memory by the system. + */ +public class ForegroundService extends Service { + private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; + + private void showRunningNotification() { + // Intent is used to resume emulation if the notification is clicked + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, + new Intent(this, EmulationActivity.class), PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.app_notification_running)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setVibrate(null) + .setSound(null) + .setContentIntent(contentIntent); + startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + showRunningNotification(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } + + @Override + public void onDestroy() { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java new file mode 100644 index 0000000000..b790c24801 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java @@ -0,0 +1,27 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; + +import com.squareup.picasso.Picasso; +import com.squareup.picasso.Request; +import com.squareup.picasso.RequestHandler; + +import org.citra.citra_emu.NativeLibrary; + +import java.nio.IntBuffer; + +public class GameIconRequestHandler extends RequestHandler { + @Override + public boolean canHandleRequest(Request data) { + return "iso".equals(data.uri.getScheme()); + } + + @Override + public Result load(Request request, int networkPolicy) { + String url = request.uri.getHost() + request.uri.getPath(); + int[] vector = NativeLibrary.GetIcon(url); + Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565); + bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector)); + return new Result(bitmap, Picasso.LoadedFrom.DISK); + } +} 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 new file mode 100644 index 0000000000..070d01eb1a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java @@ -0,0 +1,39 @@ +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 { + 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/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java new file mode 100644 index 0000000000..a29e23e8d9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java @@ -0,0 +1,35 @@ +package org.citra.citra_emu.utils; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; + +import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; + +public class PermissionsHandler { + public static final int REQUEST_CODE_WRITE_PERMISSION = 500; + + // We use permissions acceptance as an indicator if this is a first boot for the user. + public static boolean isFirstBoot(final FragmentActivity activity) { + return ContextCompat.checkSelfPermission(activity, WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED; + } + + @TargetApi(Build.VERSION_CODES.M) + public static boolean checkWritePermission(final FragmentActivity activity) { + if (isFirstBoot(activity)) { + activity.requestPermissions(new String[]{WRITE_EXTERNAL_STORAGE}, + REQUEST_CODE_WRITE_PERMISSION); + return false; + } + + return true; + } + + public static boolean hasWriteAccess(Context context) { + return ContextCompat.checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java new file mode 100644 index 0000000000..892b463877 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; + +import com.squareup.picasso.Transformation; + +public class PicassoRoundedCornersTransformation implements Transformation { + @Override + public Bitmap transform(Bitmap icon) { + final int width = icon.getWidth(); + final int height = icon.getHeight(); + final Rect rect = new Rect(0, 0, width, height); + final int size = Math.min(width, height); + final int x = (width - size) / 2; + final int y = (height - size) / 2; + + Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size); + if (squaredBitmap != icon) { + icon.recycle(); + } + + Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(output); + BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP); + Paint paint = new Paint(); + paint.setAntiAlias(true); + paint.setShader(shader); + + canvas.drawRoundRect(new RectF(rect), 10, 10, paint); + + squaredBitmap.recycle(); + + return output; + } + + @Override + public String key() { + return "circle"; + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..c997266857 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.utils; + +import android.graphics.Bitmap; +import android.net.Uri; +import android.widget.ImageView; + +import com.squareup.picasso.Picasso; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; + +import java.io.IOException; + +import androidx.annotation.Nullable; + +public class PicassoUtils { + private static boolean mPicassoInitialized = false; + + public static void init() { + if (mPicassoInitialized) { + return; + } + Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext()) + .addRequestHandler(new GameIconRequestHandler()) + .build(); + + Picasso.setSingletonInstance(picassoInstance); + mPicassoInitialized = true; + } + + public static void loadGameIcon(ImageView imageView, String gamePath) { + Picasso + .get() + .load(Uri.parse("iso:/" + gamePath)) + .fit() + .centerInside() + .config(Bitmap.Config.RGB_565) + .error(R.drawable.no_icon) + .transform(new PicassoRoundedCornersTransformation()) + .into(imageView); + } + + // 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/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java new file mode 100644 index 0000000000..9112bf90ce --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.utils; + +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +public final class StartupHandler { + private static void handlePermissionsCheck(FragmentActivity parent) { + // Ask the user to grant write permission if it's not already granted + PermissionsHandler.checkWritePermission(parent); + + String start_file = ""; + Bundle extras = parent.getIntent().getExtras(); + if (extras != null) { + start_file = extras.getString("AutoStartFile"); + } + + if (!TextUtils.isEmpty(start_file)) { + // Start the emulation activity, send the ISO passed in and finish the main activity + Intent emulation_intent = new Intent(parent, EmulationActivity.class); + emulation_intent.putExtra("SelectedGame", start_file); + parent.startActivity(emulation_intent); + parent.finish(); + } + } + + public static void HandleInit(FragmentActivity parent) { + if (PermissionsHandler.isFirstBoot(parent)) { + // Prompt user with standard first boot disclaimer + new AlertDialog.Builder(parent) + .setTitle(R.string.app_name) + .setIcon(R.mipmap.ic_launcher) + .setMessage(parent.getResources().getString(R.string.app_disclaimer)) + .setPositiveButton(android.R.string.ok, null) + .setOnDismissListener(dialogInterface -> handlePermissionsCheck(parent)) + .show(); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java new file mode 100644 index 0000000000..74ef3867f2 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java @@ -0,0 +1,34 @@ +package org.citra.citra_emu.utils; + +import android.content.SharedPreferences; +import android.os.Build; +import android.preference.PreferenceManager; + +import androidx.appcompat.app.AppCompatDelegate; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +public class ThemeUtil { + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + private static void applyTheme(int designValue) { + switch (designValue) { + case 0: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + break; + case 1: + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + break; + case 2: + AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM : + AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY); + break; + } + } + + public static void applyTheme() { + applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0)); + } +} 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 new file mode 100644 index 0000000000..50dbcbe183 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java @@ -0,0 +1,46 @@ +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/res/animator/settings_enter.xml b/src/android/app/src/main/res/animator/settings_enter.xml new file mode 100644 index 0000000000..3c216a054c --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_exit.xml b/src/android/app/src/main/res/animator/settings_exit.xml new file mode 100644 index 0000000000..a233b6757f --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_exit.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_pop_enter.xml b/src/android/app/src/main/res/animator/settings_pop_enter.xml new file mode 100644 index 0000000000..080bc27c49 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_pop_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/setttings_pop_exit.xml b/src/android/app/src/main/res/animator/setttings_pop_exit.xml new file mode 100644 index 0000000000..4fccbcca2d --- /dev/null +++ b/src/android/app/src/main/res/animator/setttings_pop_exit.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a.png b/src/android/app/src/main/res/drawable-hdpi/button_a.png new file mode 100644 index 0000000000000000000000000000000000000000..f96a2061ef3f909b32fed8adda5494e290ad0af6 GIT binary patch literal 10674 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 B3_*8t*cliYy)#21N+NuHtdjF{^%7I^ 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 D^T-81 literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b.png b/src/android/app/src/main/res/drawable-hdpi/button_b.png new file mode 100644 index 0000000000000000000000000000000000000000..b15d2b549cbf3bec54b5e45a9198084927348d5f GIT binary patch literal 9479 zcmeAS@N?(olHy`uVBq!ia0y~yU^oE69Bd2>3_*8t*cliYy)#21N+NuHtdjF{^%7I^ zlT!66atjzhz{b9!ATc>RwL~E)H9a%WR_Xoj{Yna%DYi=CroINg16w1-Ypui3%0DIeEoa6}C!XbFK1GK4GRO#s87`^C$wiq3C7Jno3LrBRlk!VTY?YMsL6+!)M1ox0?6_?7!Hx%c z#EuIQLaBKvwn{}x_IC4R65cZ~C@^@sIEGZrc{{hVBqn_BbNlamZ+UCwF1;ic?YSpq zqp-7>@TCVTEY{pD6Bc@MII}-GU(k0YvJ!j42j6B%s z>%99WlcL0(PR@;c)=yfsWxChWsC(6)kFSqYvECFCwc4xH{`sEZO(CbhuD<{M-u?IY z?*EMy?LU4|M?!G{8wa!Cf^{FP`2|>*8u$YmzHhnyj6wPYlcWOglSab?2?@ra0~rs? zXR#g#e#I7|vDHDO!?$XUW6wR)68)lj-UFI3We>kjt}inbNc8y^DLQYuJBv{thjJ%} z65khbx!+v-ub*MkZFuxTBSyPH?8t*`L1%LYZo>;Y!h5$FzbpzAIsS5ep}w$Sq=?{* zPL32A!+#bRxMi68nAOw2?qB0HrL1w83fr9E9bpC+4_kLETrQZreWt;+q~7XG)eLiLP!SgNJ9P*F0{ha9Vi{*pX z2KK_~t0rZMILH`62rO1wdRrIk>%~LjDHzF z?Bh{fAh*-Lx>0;WVB*b9b67u^J>XjMe0w9~lI`YRH&<{SaC2*QdlL7Sy^j6QvMIe$ zI@1<2+~dk|`t!T+$OqXi56_E9?@Z_QSW_VIL-oV$AhWE6k$y}&cq-JBek6CSX1;ZH zrvAF>D-An5emB-TK5FSox8z9BEwDA3I@7VL=0e;43p<=LT$TmJv(97xHg%0Fw}UG8 z0>KLN0?VW~@3fYi{W*HHw0BX5MjXpFL&Ha5B2%>*vK!BKa8zA9n5$XP{8UUu@%{1x z=O%5-;0u|>VAWX9zQ#FbuF52VWty^Ln^s)o*u!MQDx;g(o#v>@`5@L~{d?xB73Yqw zE;^#=kKJn(F zO$+8cJ7>;ZuK4-%tsm3H)jrK_syY5%Co$S@VNA>dex>f{y&n1td+UEDD*e_sI`H;{ z*QfuQncW(D8ALwFH+oOZt$b&+JFPNFfytt==Lhcr=P3pjdP_I5HdZ&7D{Hr#RdsEa z3h?kL5ZS@9L)vq@9_Laa2Z1 z+#<7#Q_Rd88~Gl@9Wk4>htb{b$$n>vQ@#Z@BSn6#XAm^CF1j0Lwq&N6+WFNbi4kW{2`Y zuOn$|H;;&7|bfp$UpToKtXSz`RHAsinS&CK(~oEV}fB)5ODi#r?i;25*G;=2obW~^tp z)i`^?&$_#xv!`*jc05R1a_h*Pnd=$s0xJ4Vq8Mf^byie0vORI;@tm3c4EOqW*j1eh z4(3wZ?No1i-F)V1hF23FsyDv-X}Z)|(YW7SIaMP4Yz%9Guz=(FUm0HA*4kGZ=7^bO zKT~Qbo}lP{{#V8+#_OV0(wioxg`II_FlYXGXz9f%%$7@bT&qt1eds1v0`sI-cg)+b zXPI=jHXd0lt|)B0xhip^#sfD6)7ktNMEW0FtdE~%Y1k&SG}L6Xrtc@Q1X1Cscc$@9 zOO@2s)zw@YvXbkw)nvWa5}$b+HyXxU&HeV|bLG-0MN^`}w%0BU^q6-cD@FV1`|irX zjZ6U^%hHSde6@?Eq@;rQZNFSNs=x2Yqf;I#M%%8;fB*UCC%gIQyWAGfJa+6@iHVeN zX-P@Xx`>TRlD%$|9mGB?3tAbVHT4sd#?mhahNg=>x{i2@1eR=%V9asx%dtFW^!e}9 zpdWw#eJMU~yZx8C{m+wbDW6?e7EB59QxMpYm7RV3_U+q~v#+g@-0|a4_hV`Eyf^oD z6h1x~p(Dor=kMRY6Km|&^Blh5G{ZsBJ!@|B5}*Hx%fv;r8&(V6uix2sFfBVf+e_1Q zN7mI<4~2d_Soga5vBiyTYm0WyseEx^;j-|!%B5dF9+%&L;{EsZYwP3Z-`J9QdE)H6 zT`OPD%3f#5V_;wqF#YsX9^IDfg_l*?y|Xm@4m15{z*4_+nfEb*X{ndA@%e$y#x1e-(@N`v1Lr|S%-cYI&3qI25FMy`Kr z+Syr0Zl=$-{jym8?+agP^SnKuC%Vh+T(WTC!u`vZsij9=+thXWSp(Cri9uT0%c>vw zn7`s$;;}OBtFE!mX(OJ)8?LX5y`3F7t@5~RdCh~*^Z);OR$yTx)9G?-E5`}t&}Hjq zyz{(#cZoq!PUe4}^OvVgsIkjGlC<$+UteG2@Av!v@A9vG75tv7)k%3_fJXS!cM}>b z^u!I%zBIlp`Azji+V?Mqwq;iaM9hdfk$oHojq<3&4N z*nBRp`8IQ*;R066_B^j;vstT*gQj|a|2Nsd_wvc7MaLcBV3Jv*CXlx8*e zo2+%&nr{ahnR(gy<<_wG^!4>k4A7XOdu{&q+-O&&i60LhJa_;UWq;Sj?(VyO{rYlu zH@82bR<^4Iq~m&fSoSWrf9S(hAuPnqv&g+6*h6jdy$GFYzs{BK`&@j)cFucGwRfGJ z$6mZx@%_ok$y;}qz0Ioo_w#wU?e906#phTQDs9TTx{7hZ3PHvB=cVV)o$DMM8~c{u z=EH&L|L@-ad$;`X;lsLKE3;%-iySyUW^9}P@3=AN1E-Xy;nM#zXDoYpW82zYyLZ2q zmXcyRnR3Z|{`cbQw53xXf32E(b#-|En%Ldjet)~2|9?9u{eGRgzUJwy=ku!b?skc4 zvoSR~+}pH9(lsG++QlnZO#bYBU;F-S`Mt{L?;>=jNy*9SoitpM+R%98lhrbT10ihp zIOiNS@~w&X5KB6GBerr|Zgi@V+F4BE_qjP%f0=r|NFh__vez2 z_g(Gp@2~FiY$}#%U$$)7#(VefEjun(oiq3Rb8k`QIjKHE8(*5Q;q*~$;GU@HeE#or zj>85r#|!7on>X*+#*G`>@@rp5+wXfmuez@I&5e!6l4>hYZ{pd1-~Qj9&*#^lO56M} zKqE!BX!GfrHjgf?xBkl?%$ZE)-5t&)-qcoqetG$FJEMrt9bBp_4<9gvj6}4 z+bh?f?pA-VP-fQb*}?kz{}i2mcXxNR(nJqa|McBkSBLq2Z9i$E?A*HUTY%b(WiNSn zd081AA7q#R^E|;|hVRlK*Q2s^`|nH1$nda1m@Z*FXiI`H^o#j3x%O9iCsWgl;u zAHHdi`MwaHwnU4UC02V1A0L~?D{c1W+xPG6>0BMM#}B^${`~jv-_4I7KdxJUJ$h|6 zqrt|&G}Xy(sy8v5b4qH+;+uAAR*2S9&Q_kPtT6w)^tRmBuD&z*cnw!J-FY(m zQscM(+Ki#W!IK%^B&C(g96xBl;~qZIqLuG^`(@Sz=fAGyNt>*i^`mCroa?W*${t_& zE%1PC`43*b0NF{u?c_KL5O{SXjD0NMrds zt_7ki9P{h+v$;0gty;cZ-Qwl_%MYIvS^B%XyI(Rn>!UY)^*2F=6x#)Cp8a*P489&w z>%R$~me_wk|Ms@G_ji})Prm*M#^72ZspK37IomN)d}x(zE&7-zB=O!zZj zr;2Tbj%LVuxtH(W_2u5(^>tH*$*ftKZpTf0)#v)L-}^l8dr7O)!Vs;gvwT%QJneCM z)WETTM@6B4b%7?R7=QKZ)v?Bn{&k-wU;p~$%N27|(_Y`_|90%H_x3)0z}@cC#PCfw zb0XF=&v6o)$-1hLRls3A!*tncsgf5nN^~T6_-cM#p1<$q%jNU;Ra{*a`q%jEGl{l? z4;~~u**yR6o9?%7-*z56==k>E94|S}mcw#OzJFKlPMBqMU|a6t_ur$>o;}NXywC8(V(A_B;3D#fy1E_bC4Jx#8jEvX(uP z<@18{hP2k#O*$8@EDG{^__b>7u3fu&g0xs?+}@ra{~J^;$?EU@67>9L`uw}kKUTbX zxoYpv8oTWK`)YlCeSMYtZNJ?J_R>r)DY+7Uw6yVDOYe=Ez3d-bV;El)7O~y&I{WtR z+rU34BOlqxBJhkoZfqQ$amG$?0IP}xs?&lNM^Y;J$R4&W3YW+3;Sjn;X zU#r%xTeogmfyJDgJByzmPM=$PEn%U?w6fg$9nCjt*0%q=zICNmjotjYr=RxB|NACg z+j(KYwh7Y$OuRIYZ_AzPx7_w-&NT^H*{|wCofjS+ZdYdKm&>`cIoUA|Ms7*nolL#4&K{a9ewN8Ew=JEH$J{_6_34g`S$JAz4cWh0v{9@H^?b< zFIl%vZ?f0Yn(g<2aif0dbM2nbDQ3I>>)Lj?`sdd7D_@+_UT?!?!OYCeyEDdb zzHE#P*9Kb;qdEH$Q~NS!`5DdRdGfi^)XwgmWx)f7$KT%GKL7vQ_WicoQcq87mjD0b z`2UK-yykn_?2NWXcgE?le znOCe{ExmsIdin2Fd$0M}Rz5rt@_f(dbFbgNd$;cI19tl#?|1LoHEZ9mtLy*yXkHCC z!G1}pLb;8vA^qfq3lr*(c8My3a?WE=3#!D*_W9bj){D+dFK6Z}P5kiv>(^F(8H<4J zaN_|+luE`y;{k=d)Ka{+}zy5AUD1&YSuYz}Z#0=kNFX>x*^7q<^1DxBI->MM-dWw)J#Pwh7-CbSIwea+-%P-0AY>&F1rR z)ejokxBYz;zW>)jR`Hk#FJ8UM(wky!^Z54;_hoEMjd$jj-~0J);lhQtjvYHTWu7FH zTG0Q?Y`+ZNY+9tnsr{h&PZKl$^sv$4Owy`8<${QUFJ$4*VvZts(^ z6x#iM-*2;@HTwb&Ux+VdY;I!s5_ZDqxUJ{D-Me>NO7K`&m%XX@qMKiNdg8iu>t6Vn z{{Q@Q&&6xk%syOj=8ygK>9l^inYnqt$SR*~g=B^2jZZHYI^2Bxv0}};ckis0PN{m- zsa|*G#tn;G*RPAu$}C&HZJXKblPONo+w=CaoRDe@e0zUuv{dR9hrUVInN|qc$o1d7 zef#!}ix)3`UaDXFGW_q~zoy1JO@G$h>o}Tp*#6(g{_AoU1q-$=T$p%klU8VhQ%>Cp zQ3o*t?^`Oq7d4iy@lm_{;bMQ?m#Lr6+wZrY=y4|E8>oo9eEIU_4`+=A8zXbyU7Iq^X=VER+xNb0dmOjkJUjAQ&rauy86|J7hR5gH)%++( zRuNkH%&WmJfyG10!{`o2Rd$MNq6FKtRMvCW@Av!zbs0jmOq1EgxLS+U{pYRu`FZ|- z8B^cQQR)9>j$CtZxX!(XZ$a#WF6Fs?)w5^MK5k`Y<#y@immVeq-^)_c(x2t6N>=cl z2rraLP%``fiz$L%NHY92?@EuNopV;LTzPWqmMu$;A3uIp$^5{IdGqFdNZP)(C-8*E z1!iHU7w*&D*IVS=4qKLaYhl2NS(^DsJ#ggwB#(B@q3-IoO>+mP5Z-=D4Hg zQ;|DMv+laQch1=>uEt`}(ZkdzWa?$i&U5&{w%p{JnmZb4%6o!deeHfV_XCH*-v0)3 zX0cq4{gJ%&-i)rpufIkuUcC6;ym|8y6a+SOMc&<-8_mkpD08(kW1cm0$)&$XQy693 zk28q68c3Y^{`D)X{~QZJ&Q_czNFjkGfU*|y{z2QJ|S_@t*W-JgWGaN z`Pw%-JeybjZf99Rfq_h?OPWu1fR|-$ZS6vCy&VhQ-zh#XDp}A&yi#C&r29f z6pNSq^0{fWWW&w7tJbddt@%9re$DaA{`R#OSB0)-Tee#0VbBTLPcfxeL){y5%x16M zws&Hj%K!BLPvYm*30FXWzbko4q^s ztgM_IpNY9S|J|a~y1#W!&ngtZX|#mR!ZDHYOzNt*1bgA-$1=Qn9$TC$lv%uWYw3z* z%hFzeirs&~^M760owV^rhK0;IvB$rwZRD)w`rY$(KApCAb@=*saxyYJVKe`~m+Rj3 z|BJoP(^q@Aa@>wHsUBX-`|4}e+hfO$sTgcaKR-_t)U&hRmV3J_(B1vGgNDc{zw){n z-9e`$rKGr84nMq-ZufcS{oC8#Zr53T%WK8#6U)}y&;QrHlBGaEQY%VezNay3tEAFpjo47vRxuKUK9iI@KyOl6RHVEP~?fg{%Vg~%PJ zEpOkx{d?y9oCJ+EXTU>zk65KqU1$D&iQ$^y4L0IVs;ikKUd7g&Ypey)~&YN zw{8jjthuKVR?-^$I5|9~@aR8K!0-238pIniKRcT_@qxb2!;5n=w>|eiurR9oWq zty_ED7GK3(wYm)(vV%eE+>R!C*#yM#hO_NgGXE=ZM;f z#;w2p?%lg}zRNGqn`>Qex2x*wtBU;m{9;ildk2=8zwPa=$}gY&{=aa6-KJ8X-Hxm= z;u&9GUH!}bv2L=F(bXq!&dfZ=WhZPm|GZVv6OUT8$%dd@;J$v}uUEAnD{R7A>+F5b z{PVf_`|ug29ekehx@H$wWoc|z+U%uy+<+&p`t8Ri&i*ETYEk%V zX=#UVJLd#4T>kpT`NWn$;j7{np(1kq$K@m?IYC|Gw{P?7e>aB5RX(krZI){#(SJO- z?@F|Osjzor?~QkI%VwUPb8_oX*+~{lf|r~Y=s%vEzB$rAJUrZ6ZE|4z|6kX)|NS(5 z|DXGyR(yuPzkl(<6?rRXM{4;TDgJr4#DA9N^Yr+KQLRh7!r$K25swK^2h|b%$Cc~$ z+t(hKE#LEH=lR-qt=#&1KDcO2&GJ#3yid!XO8j-yUcSA4z)Foz^n#Az z*C$%@pV5H56Jf)k7nF`s#jiZo87;vW$I$e z4Y4Pv{|BkUr`?L*<#@O0DCC8+1;4EWdmY)GiDuEVy;+mRsJAhi&V!*YExI z!Pormminu!Lhm;0U%!6+&270;g0ANMQ24$wlfHK6%zMSsu2}XV0Ep$*sR zSB^aX`0Nnlt*dWd&+ltqVrSzixHvDR@1w8?##)`&-X7YE9={`b!#tM^FfnOscfJ;wdrQg zl7ug}KKLJHd?nETv^6Pi!K05A8}sW-KPaY5-En2_lcuD|X%{bDniS-fs`2*!*)*kV zMxTG~F`w%7(`aT7? zL2A(^u{6Ug87AE=tXr7&yNA`KAB{=3ndfFE`9djbR**2;F@f20Op8SwlyeMtsBg?XfKJh$~p3Eo3ZQ4$u~C$oc?!S$?3w!hBVC$0@2nDH}36; z;9#^kaQH+_&Vf>YK1|Lq}Fv(33@{@fw# z>GaBBx6`cF<4!rRSs#dVe_h5s_t$R@0rzFgw{Vznx3>ChFK6!8dm^pBh2dCZ`-H9L`7@riZo>@uEh5qf{xinD&PuaxxOu_vI&q+VfjA=2<2y-~Z^P68p?y^Q(gl z49wY}wE&FM5>^#kr3K5BZ`?3ZXFp2;vk|jF*E*Z+cQ#CrvI~50{LIF_^G?BhOa+xp zot$m0sS(b&<4(km92K_< zN-KDBQntn`t~_(w`}Q2;;CD<65xjT$d}fy_uM}B2Z|^0a-rhG4vYh?0ekvNzxg4FP z<;b95`#)aU-Q?*7#v;o{Y`f%^WE~a`Ilz>@l;c3EZ2`BD)QcMnY&J|t3O(zw{=dqm ziA$AMm@qc%us3JCuCToE`y&61`mAQ3Vz(&CUfi#cdP+M>akCKXgH1ve;vdwWa7#G2 zENI=ZW5-G!Uk~;#>PJ5GPu~<+ymS)lgCffheh*?)Ru{17wj4cR`p4>t$~Wc%=OdS7 z@p>OE5@BCBh{GAi{t*28C-Pqgu z{hXrhABLn4>JzWDES^$vxpL=XR)cDr6CsJ&4>!ts@~?1q5lna_Qp{|a6fSUX$?=r| zt=D(-zdOI~#HJ3%ekJaj<_>-PfQmQEnSv&q?ku`39K-o!V#u4z)qM8Cmpi|3_*8t*cliYy)#21N+NuHtdjF{^%7I^ zlT!66atjzhz{b9!ATc>RwL~E)H9a%WR_Xoj{Yna%DYi=CroINg16w1-Ypui3%0DIeEoa6}C!XbFK1GK4GRO#s87`^C$wiq3C7Jno3LrBRlk!VTY?YMsL6+!)M1ox0?6_?7!Hx%c z#EuIQLaBKvwn{}x_IC4R65cZ~C@^@sIEGZrc{{hVAm;1V=l0+4-Me}#TKA;(jbluk zrJM_z8e1N;n4e5gU`}jeuaLX9XTuqv)U2SEkXA3zB}@zB+5{B(9h`!moK@MA@Zg}& zz2*rms>jSMT39|Po$k)Ld24!@{;Q|=tn26O^AMYMWm43uuQ&lR{AjFNAj!e#+p#g6QMBWtbsJyOnXqqZ{XZ*>w!HC`FTcEL z{baWmLH8B|mJ(*$)iiB&+AuhD@Q)i~wKz?SsowSf6@CdrqJr?g-6Z&7aJa^dkzu;0M_Lpaqyd&({brUOS0v>pgL zvi7C>pTxZl*F$?EPB;X1yUer_P~6k{MBZyskf?)dfo6f=rUkOIEwrv*vx_v}PbVz@T-~SGd#2pjFr}Fo7b#*z#AI^#7zr$0*)p`B95~sH5qv!m~ zMU>draSJ^{9gCoZyj=p9|@8C9U;B7TB7 zhBc;5qR#xO>?s`)zrDd6CoSGH%yU1oF(D$u_(0`>`;5<(FLpY*tyUHN+$|i@q4=}O zc!G^LlSR;h*R#a9T^ZsYus!fTvEqJbkKL)enl@6OVtJHqKJA{sbcne_qH9*GLW6O` zdZy<}ZSw9e3pDgfR5%1(H@y{C=ziAc6n7;=WH-Y)raYILGmlqP)NOoRV}35@gXfYd ze`?k4v4jM4g)*3Stgn|8y4JJE?Qd&K-x}^m2O@7qOS{f6*KlLp!cfk1dg9B=EI%++B_gdJoD{N?tf9ai03&-M{gv$a3Y2d%sV3zdJ!Biox+g z_KEgF7SY@KrE@gyDQvQT%#@N}bWrSgtB3=0(x2{z#Txbd=UkXLa{-^{oUMx7Pb!xO zFh%hE;ZHg2s($LJrhAFXp0{sqS$i;6Nr*ZzcsKm-6xmw3a8~hDW2;Hp1xhaMGoGI{ z`>flrwBvTNLRR>(^%o|-n9!Hf$8X%PG{c|KgfTw-%8#kH7Z((`Ja9^D`g`WG+n=98 z4z@{u=bM0 z+cS|Z<>2a`ctcUPNB?$AXf(E%UZ1m$^~3iQ_wFWZJPv=-$Nf<(tKt7KnOo;#xE@5Q zTrL+${nPa_S%LZa$^-TDHrpGycR%n~Jen)Jg7uuO88P0M->U7w_9IB_rYY)RWkna7?uGt8b~TvGk%OBL7AlnjrIEkZx~ zH`i{wDfPzd$oKHN{YiF(F$=jT%BC8pZDZOYs2SsR{!^()*USt2f7q8iC~`aF#_*pn z^3S)kV9^DN3om9Z$^6taW936LruxGfpS4;S30TZMr0-_M?#hT9ZRp$aaP@?=Qt0XvP?_kXHd*A|-kB$s@lRkp zcA@mHEDzgVyZPr?6SPBpj&c}r$1wgW*7H9R${_b3$m`12m+#-p?|ZxL_PPI$#P?T3 zm6w<2*rhYZUf=4Ymh8It;+YR03bHCHDxTcfn7nbZdq1DNT}{P{3k#i{CwioCTrlzd zeD&(ppRsz=(;}w{9X@_>;-oqLO6oVi`+seTVJ$E;`X#^ch+BO_iIwfAPXC&ukH>nY zuP?p)vMzVtUj^UG20Vu|`Er=cY~=jEe))3b_U+s4_v-)uowxmd-R~RG`Fl%C&d;;8 zZ4l*PS*4||Z96SBa`OJFn-g8q8c#RI`+t29!;+xz&c8)S=I>^S+S*#qgbyDc9(Mk$ z+n}>8_x3p{7h@jh&p&J4mA<~FyYI;)Z@<5R1C@cSWJ)nI;^PBLbZU#MvRkq$mU*y}DWkxL&JEZ>N+$J4s zx&GykKUUn@q!WGFL|1~RYTf>Szc#Pm|L<4vz3TUS7u)~)*#G~_kH`K0Ev0(hq@<)? znfeC5W>M_B@rLcef4^@BVt9T%dc!^K)r+527|9Cn*e&0*$`#;Y;Pu>`@W%+XTXGJ@wa459N?Aw&LiQV_BP9XyvwT+a8C<*M8lpzyHst@CKjy`uaNtJw^ZTu?h009d+TK;luEH zLe%S&dHSnVHso5bH$T1U%}l+7&5|h~cbezjsd#qT-@dlgXl9SP5a;HAnQxU2Ix*P_ zpJSTr+IVfztms;v)0=W)uWx;NdU|;J-JTlP*pkzm)_AB)>T*$1UKw)gN@;D>wbie8 zm3??{aNXJ2=J8vSkM||_+k849{jgw1=cm%dxx$*~ndUuLY5wWLWFg9_>^sv_+q}kZ zzV=+d^mqIJ|GUo4&hCCE?|V;wNnUgYNGt&7=PRr>bSRPEPV+S=N` ze*IchTU%Rr*x*ZyTaoW&1K-Q9-oBNsc+kkM_y28veRab5dA5IhdwWAu=UDbBd)_H- z*na$f(IWkP-JpL7i_#i|KUD2q=c6{c@y|~LxUn%Q?Ib8(T@C9kI~&O80TC9ZRMw0xuG{w=KMa>Qpm<_)@Dsw=^>>+r*Z zQVW^9S8v|<+$}sV8@)Z}=BJ$6+P&MCEjyM!E6p!#j)%%6z3Hc`FZ-I$ZGCm=ZAj-l z+kfKf!Uv>M_UG@^o5R(o8`0a_E4p>-*0cV$U#~=Ozgu?uKX?1#sdD}5>hZ^(7QJL< zX5M(d?%U?&CZ?uWdHMLJRhyh=3G3hgIfW^rvFG1Yn9>}HJUkxZTZ222PaO|4!^cGet%t{*3_=)g&tQ5oBbI7B-b0pF?df9 zEiQ@E^EOo7mU}yHefag#y`Nsk|Nr&APtNw1I9Dsv&L5wbW!`E_+;M$fthC$N7o1Nv z9J_q%o@E@v>Ir($&un6+D6#9D-Xtw0)%5r8-&*@$7yHYuUcJg%>^Updy(atVsi~z6 ziL+AwZ++F#JHOy~grB4Cj4q+u+jM;|H$`vH%YFOqog5Df)4P@PCa+cS)sNbuA;G~? zb24Sq9MJ`en-6^2Sl%bvAnsYW@`b-((rvTQ(9lNP>Th4(-PpKTIow=hs@Kee2^U^} z{%Nyp7SkgK<40VK>Rbt&MSCuuu!*0dLt9(> z?o6L$I>L;}9?I=Z>9*YL1~UVL31>`KuYSF2n&0xbDMpzqKV_IKnfpKKIbZD={aF)T zop#oHxSlWZl8VurF4le2XrhP8+p^uEncZucr6z3Z=w}J&&uIw!_n&FOvSr66X9t~r zTedrCV}y}h|MHU9a3+aZc9VlE7^F6rr*V}8cFCOB=DS5oN{Xq$?y671Z8K3((RYuJ z_q!)=ywP#}_1wajWt@qMHy^M`h&phD{ty1LBuJB?+}qo`R&DZ3qa`!n8ZUW&clUPw z6FXu~{gaG+ZgzMD!>j=A2aDV;n3^|2-f1_JX} z)h^DQ6&4yA`eEtxxLfzHUpEhkjeR?F{p(l;3+{(mvYhO!f$np~RNE3?O!QD;b&;=r zvr&7xUTl# zZ{LnR{#fyzncwC^tA72@)7kto78lGzZSI@+YRm9lxBGIz+4|et+t&l*i(Rj*s!`($2j*Ev0FO{|yI zT(jt?s6)HU-dYvhul>GPj)P@Y;KjWkEtpmmDsm^Nv#b(3X!+>M(!*rS34Vd5-VW zps?E7z5Z7j=8Eug>=20eNaZL#VtC-&w{ME4H~reZdw2S6v7_hi@ytK}-1hsO;@A2+ zpG;DnZI+w0^HG;}*wP@)?ECv_W7`s61o!?~9kzCrx7y^F{`J2u+uz<_U+H(xpo`?%rMf z;gt6JKg-MS*M85ikl~A-wzQ`{NoT>K)||$Wcm91x9)H}j?)9zYe%o)X(@!rwZ?Y@t zw%M+AkBfGiY%(sek@M&0;py43b?aB=ZObxmrHIa%>2qw=s#Tv%O---n-rlzMYnyal z#kUg^m6>~cdlO4bE=`%yaGmu>fU(0li@XybA0J=*SUTF5{rS$mhB?90&BeqzYZ+(+*W=*XI}lG z|I4Git&=#}n(r-Jw(L}h*3_zRH`DL`zW06K_iN|oTJy6qHO_kVDr%Lx3pI70|ze(!zDjs>~@B8`eth8B9#I3t`?@skl zG0HNVA+^;tW7dsrx!JRpUA=bg7ti4tg>nZRFPm(Nl|EBql{;%$rX_pw^Gy$rZp;1s z;o;$C2J!fskDtu%RXon$|FJj!&$mW)xeDW%K5VaEzh=%Vv@z7X!c-CUV%NIc+j8Zt zN-_+pBaZdN_8Knq4KFR-YA(X{Q%g(Broqn0Xwtj4Z+rWDdQz4NHBX*>V|#vld}-;{ z|Nnl!-=B4MmMO3K{hH68ZEbDKG(=Vf>};I8Xy=5U8K1<9RRlP82zR=CIwqZ8vv2Fx z(hbX&r5#g0V!^ax&6<+ySFThzEeuGwxheH(_O&%DOE4$v(#3UsoJiNNtHfFKUg@ztw zhNs%kI+E)xzjgmDpU=5m@N{bUx*hL! zy}mZ5^4ZMp-@kt++uGW0Jo%0J#jbUc(^9u@-C7z~eAYBuzV^$-7qaDdJ}$ccy0p?} zo~E_u6hW&AWr}k*O<^cHVyLkH{`*U%cjtOffA(^MX+qG-DLa#o_nniqE|d9jkiTw2 z@#$&0+3$C~Ue_HR73K8%`}_B^bIeXmi@fpn?c2Mco{H-GeZTXrPxiO_88%aXrj;D8 zLFp37X+@1)EpK)m`c`36Wg)|7W@ct~^l{e8_ zD_4FrF*P*>wH4zFkBZt?zgoGxY+lu?m0yEpm#lrg>)P$x(w~3-ZT$Uy|No)`K5CP* z*aUbKRx00BxUwSP$c=5inX|%zgM)idKV5ZWQ|jsU+w<=Jx-P06)^h3PmnCd(%XWWl zX6OHwduz+fGurF-eEQPZ$Y{#VVI@$S#vsZW#V})8W~6 zr%1HjyLZoSN8#gRqS9tL7O4dV2CKYOH#F=}ZZOdj+LCZ0#V9N_HTCZ1^LD$RZ@nI8 zy(8n|BF)?LR3>{~e*E#r`hCA%T@LWQP?B;%{>sF~kA+v z!i9h{&p-P{U0<3h_4n`J&d}9iSLfD#yUDvTBInezU8+5eUk*xLo5=dXpvY@UQe(nx zGrRfc%O4zQJlM?6&&SeyP;>wO606*6*RK7#wkA?|(VpnUT@w#KXqVYv&k!oH{lSOd zf2-o-<3Bw(*zDZsaA4};M#*jKUat%Gx82J2bKU7et_>621e#Nf4>BB|$iG;|egbQE zqQtD!$eH{0?Q>c8y#D64Tyt}CcE;pYA2K-~GyO_1`EPz(L61^ahzjZdQtA7Ueyn2Lx4I z*Lf+4G3;8GeKN)9;-yPL+q15&(&RrY-CXwP$43c+myyh>J*_vaJ`^&3QOcXJ=)}g{ zXl9}L|NlG>XJKND*P1$OljyExnX@uy&2(<(;|-cu?QqBA>u1I1eH)oPl=Cj+@Ye*K zF>FiBxO?~R;e`PjC7;il&%b>8w)E+vb3azt96NBp;nCmk_usSLN!?*@(Xghpdam`c z-PY`Zk`F;mKHZ5PGaTL=WS8Glb8?dE*1v!MevH}Wef{dypWol#zyJRryZo21kdP_S zN6x$LncbZHUPiy+?}=9tp>1M{vzBGf^f@LcCG~38+ikb?_C1{zt!Gp6;=+Lx^%E8m z(^A=)82`UszyIDm50$pfSC>3(W8KobH~A0ybw(NIbG%_9f^CT}I$e}5-@jj<^ZD6X zaZn`}|Ki=dz9&-uzWl6Nmtr)t;@i#i=!u7p7tMb9*W*+6{~Ol+W<^^RnKZuV7P~rb z)1Gth=1ofr8=E`z*JI0LKYqFFAAkG$_308aGCog)P8#}Nj@ng{dDdHh@0R@M=jOJ* ze)a0q+dB!;E138cs^je*rCff{en50ZL5AbS(pc~1m&5Ao>qR=|7ao)B-v9gV`?{*j z%Y2`gm6h?iDovai6C=;C|Ni@Jxz?W_H1of6FghM3S=MRXQ2Y43%}j<{0^utdJtei( z6y`5pkvXf(YVNj8vd@D&{EpS0vwUundvnv%HPLxHpZQxsrJKvXxX{pb%{>=0_ z7PT?S^|5ll&8K61vew_)ofaN5GO3df`tbANkN7!03}PF^EktkXuSsh%w9q?#ak0Dd zwp{)%U%s4C%dy#}c7MgHRiAVsH>qq$I@+OEC>GZU#*RO|{mzD9A zmX?0q5wp(oJhz;;)!c1i>tZ6em%qQa7}WN9zc(&_YNEuHn~amp0(jpzmD$fb;4-nH zyz%tJ%*A)yx;-T&B~RYiw)SrI`@Pds?oN<&58kA_GcYppC2y$B_t;&Z&F4wL|Fbnj zxPs>e+8+P=)AkbUqhueohS&qoC(J0c%wJ%q`BP`f8TZg-xwp5aMwWSdds|HOP+7n0 z)vEYCKOS|zzkdC?`Kw*)roQByWR zF8g{4|0&g$?`j*^JXJIpMHrr6@z{U=Jzx7_#_6Y@zI*phuIkfC_40pzp4->IKWlzJ zhk4J#f*pb>X&N6?#ifJ`>+6oQbjP>Hux#Mw?7XJb*%Bn?sA$7x{ky%fKdZiAXUwka zuS-KiLo5H*?Uy#Nwzf`Qzvq+JZSD1YiXNEn|LJ>so^5p>^Sc0zD-(PRrmo&?JdeRB z;eX*O_Gd~A+!f3>dY-U&1u~@7*6x*G5xK8sr|JCa)Y91Nn-+#X&ySjx8hJ6p0XRD2bP!U%kDp zO-NQ&_T#y8=Q66RtDl^hs5~)5%XCt0*Y<7OwjFx>G33_bGpD!eDb#d4+&}Ad^VYVu zwwU-I#s_*GtW~E;TrQ23myz*NnS9d8gmuHjz|%ihDl_*aO0;Q9@Ko)IThG|~@w}LX z*ansg)*sJf<{8Yjnr7Z`dcv6~&$X;Nr#CenT%az%QSoedaK~guxyhXWX3W(-d|uLi z)8da)W9A#kGNv@tv;ON7is4SF{n!-OZ1g2EQAzcWzJlkSIWyjw?~^^ryW-7;MKd-u zdM$aO`~0GO+OzlnxHnzUSSjhSalyukjz7&kT6EIWlp5|HuyznOdaEJQdW6ZTBmL8{ zlKD2l%fGXxoSv7pXsL#Y-jbxs*FN)Xf*DJcFZ=GiHdDKcSvbSZ?ccSTdk*9rlKbB^ zW#+ZTE(>PV&k0j!K2z|e{ib^DKhBwTR|Hpg{d8l_YDk};?4N!lhqXXp<<5wfC;{zf z{KvoEY-CAE^RY9sKd(Id#bFIWjc0P)l0TIX=%@cq`YF)B`-dlU;iA5Xpfjd=OJtsB zZaOY=wyoyrzkY`OZbi&oM`IF|-hI>dx$rDy*OSdZo|bp`Z$f3&Q`ftO83F%T5lWca~{3&*T<3qnkM99{N@F&dAQ;tVHSblR~0lUKp}hNOE1Wfgl}<^KNL zc;+eJ1n~*3AA_c7FPUNIcO>2Z!6Q$LH5@xckJH%HAJydVbzw zwd451yeKQwc!`CiTF|2h&OQ5U?f$)}+2;6yQO+r?fo%$h$l@6ujkDz|rWB_CJf~VL z$~L!yqv+WI){a|?r$kKFJ-S6Q|Bt-&$wIxE`HT%64{TjnN>AK!=v-h^D7Hs0W$uph zefkRom>4=3>zU3uIW2i*aqUyU>UXyo7?`(%)&d-6k(&5vX8S~??V!m%hEBe{yldFP z463A-o|2kwrxs*-r@iNoZ0oKGPFxI2cD>h4slF|h;+wF@C18~Wd$T_4t%m=tIa&G= ztzrx(+CT9HS(Mu;bvcz}hMmzB3%a^fLUUqei>6=xO#UuM2EN@BYYzVOSl%h&#j>Qr2ief{dhd%{_BqBpZ7Be_pW5`zyeje$;A2ih@+QP!rcI%0b|NT!E zHXn`&Si@A1;d<$jcEi&XyBu6QyTsK`ZDuKquWvN*(~jzjWl-4oDEGvnA0V(509^U2!{4tTp4ayf8%B}a{%L>&Mi8b+D}}4r?;vxOb|@>{}$G}hW}2_JDDJ- z8Pzi7%NaXA{9(%M3Hxy5T1jLFQ-OqwlB=!9i4sdACChXdO~ohj+?^*(73cro&+uRR z=I-4JGtW8RJlexnBerA8MSb;Id*eA1zeRZLt+Tz*zhmKgqvO9zpEm~yHZ;rckolpq z>1z>3oPSyFLtKqlz(mhgpiq|%GR2K`Jydjrl z61c*FQ$f7xfKkfc4H7eDS8e((-LWuLLbSDWhyPEZ<2SBf+-xqsY5%p!d)c^Wu2kwc zDDTe2-mjn(;L{*<;LAq^$&1h4oL5_-wo=Md>Zbs6X}WxV@9vw)d5U{X2yLc>}ty)m-$$febS(^VSIh{-+qY_`e1 z)KBKF?vWF^M=k_+EIhvHzns&yZoc&V)c5P literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l.png b/src/android/app/src/main/res/drawable-hdpi/button_l.png new file mode 100644 index 0000000000000000000000000000000000000000..e19469a7b8b5bb40a2807bc4186134ac571dd7d1 GIT binary patch literal 2738 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 PYHE7A`njxgN@xNAFxK>F literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..280857f642e4dd2ffbd1df75ee30bb6de349deda GIT binary patch literal 2795 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 literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..c47d342535e994d1d6af2ae1195e38558107b308 GIT binary patch literal 5784 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 Jmvv4FO#n*tc>w?b literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select.png b/src/android/app/src/main/res/drawable-hdpi/button_select.png new file mode 100644 index 0000000000000000000000000000000000000000..6961b88d2bce8f0a63f0a0116be6fc8931076a43 GIT binary patch literal 13280 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