From 13e79aa43ff02a94bc49e5b239780cf45967b10b Mon Sep 17 00:00:00 2001 From: Jarrod Norwell Date: Fri, 12 Apr 2024 02:00:54 +0800 Subject: [PATCH] Renamed Android module --- src/android/sudachi/proguard-rules.pro | 24 + .../src/ea/res/drawable/ic_sudachi.xml | 22 + .../src/ea/res/drawable/ic_sudachi_full.xml | 12 + .../src/ea/res/drawable/ic_sudachi_title.xml | 24 + .../sudachi/src/main/AndroidManifest.xml | 95 ++ .../sudachi/sudachi_emu/SudachiApplication.kt | 55 + .../activities/EmulationActivity.kt | 509 ++++++++ .../adapters/AbstractDiffAdapter.kt | 38 + .../sudachi_emu/adapters/AddonAdapter.kt | 37 + .../sudachi_emu/adapters/AppletAdapter.kt | 74 ++ .../sudachi_emu/adapters/FolderAdapter.kt | 48 + .../sudachi_emu/adapters/GameAdapter.kt | 99 ++ .../sudachi_emu/adapters/LicenseAdapter.kt | 39 + .../sudachi_emu/adapters/SetupAdapter.kt | 75 ++ .../DiskShaderCacheProgress.kt | 51 + .../sudachi_emu/features/input/NativeInput.kt | 416 +++++++ .../features/input/SudachiVibrator.kt | 76 ++ .../features/input/model/AnalogDirection.kt | 11 + .../features/input/model/ButtonName.kt | 19 + .../features/input/model/NativeAnalog.kt | 14 + .../features/input/model/NpadStyleIndex.kt | 30 + .../features/input/model/PlayerInput.kt | 83 ++ .../settings/model/AbstractFloatSetting.kt | 9 + .../settings/model/AbstractIntSetting.kt | 9 + .../settings/model/AbstractStringSetting.kt | 9 + .../features/settings/model/ByteSetting.kt | 25 + .../features/settings/model/FloatSetting.kt | 26 + .../features/settings/model/LongSetting.kt | 25 + .../features/settings/model/Settings.kt | 120 ++ .../features/settings/model/StringSetting.kt | 26 + .../settings/model/view/AnalogInputSetting.kt | 31 + .../settings/model/view/DateTimeSetting.kt | 20 + .../settings/model/view/InputSetting.kt | 134 +++ .../model/view/ModifierInputSetting.kt | 31 + .../model/view/SingleChoiceSetting.kt | 29 + .../settings/model/view/StringInputSetting.kt | 22 + .../settings/model/view/SwitchSetting.kt | 34 + .../settings/ui/InputDialogFragment.kt | 300 +++++ .../features/settings/ui/SettingsActivity.kt | 171 +++ .../settings/ui/SettingsFragmentPresenter.kt | 975 +++++++++++++++ .../features/settings/ui/SettingsViewModel.kt | 112 ++ .../ui/viewholder/DateTimeViewHolder.kt | 54 + .../ui/viewholder/HeaderViewHolder.kt | 30 + .../ui/viewholder/InputProfileViewHolder.kt | 34 + .../ui/viewholder/SubmenuViewHolder.kt | 46 + .../ui/viewholder/SwitchSettingViewHolder.kt | 51 + .../features/settings/utils/SettingsFile.kt | 29 + .../fragments/AddGameFolderDialogFragment.kt | 56 + .../sudachi_emu/fragments/AddonsFragment.kt | 205 ++++ .../ContentTypeSelectionDialogFragment.kt | 68 ++ .../fragments/CoreErrorDialogFragment.kt | 47 + .../fragments/DriversLoadingDialogFragment.kt | 50 + .../fragments/EarlyAccessFragment.kt | 87 ++ .../fragments/EmulationFragment.kt | 1048 +++++++++++++++++ .../GameFolderPropertiesDialogFragment.kt | 78 ++ .../sudachi_emu/fragments/GameInfoFragment.kt | 179 +++ .../LicenseBottomSheetDialogFragment.kt | 59 + .../sudachi_emu/fragments/LicensesFragment.kt | 132 +++ .../PermissionDeniedDialogFragment.kt | 38 + .../fragments/ResetSettingsDialogFragment.kt | 30 + .../sudachi_emu/fragments/SearchFragment.kt | 218 ++++ .../layout/AutofitGridLayoutManager.kt | 63 + .../sudachi_emu/model/AddonViewModel.kt | 97 ++ .../org/sudachi/sudachi_emu/model/Applet.kt | 55 + .../org/sudachi/sudachi_emu/model/Driver.kt | 27 + .../sudachi_emu/model/GamesViewModel.kt | 186 +++ .../sudachi/sudachi_emu/model/HomeSetting.kt | 18 + .../sudachi_emu/model/HomeViewModel.kt | 76 ++ .../sudachi_emu/model/InstallResult.kt | 15 + .../org/sudachi/sudachi_emu/model/License.kt | 16 + .../org/sudachi/sudachi_emu/model/Patch.kt | 16 + .../sudachi/sudachi_emu/model/PatchType.kt | 14 + .../sudachi_emu/model/SelectableItem.kt | 9 + .../overlay/InputOverlayDrawableButton.kt | 151 +++ .../overlay/InputOverlayDrawableDpad.kt | 266 +++++ .../overlay/model/OverlayControl.kt | 188 +++ .../overlay/model/OverlayControlData.kt | 19 + .../sudachi_emu/ui/main/MainActivity.kt | 692 +++++++++++ .../sudachi_emu/ui/main/ThemeProvider.kt | 11 + .../org/sudachi/sudachi_emu/utils/FileUtil.kt | 503 ++++++++ .../sudachi/sudachi_emu/utils/GameHelper.kt | 152 +++ .../sudachi_emu/utils/GpuDriverHelper.kt | 229 ++++ .../sudachi/sudachi_emu/utils/InputHandler.kt | 94 ++ .../sudachi_emu/utils/LifecycleUtils.kt | 38 + .../sudachi/sudachi_emu/utils/MemoryUtil.kt | 111 ++ .../sudachi/sudachi_emu/utils/NativeConfig.kt | 186 +++ .../sudachi/sudachi_emu/utils/ParamPackage.kt | 141 +++ .../sudachi_emu/utils/PreferenceUtil.kt | 37 + .../sudachi_emu/utils/SerializableHelper.kt | 44 + .../sudachi/sudachi_emu/utils/ViewUtils.kt | 93 ++ .../views/FixedRatioSurfaceView.kt | 64 + .../sudachi/src/main/jni/CMakeLists.txt | 26 + .../jni/android_common/android_common.cpp | 60 + .../main/jni/android_common/android_common.h | 22 + .../sudachi/src/main/jni/android_config.cpp | 337 ++++++ .../sudachi/src/main/jni/android_config.h | 51 + .../sudachi/src/main/jni/android_settings.cpp | 10 + src/android/sudachi/src/main/jni/config.cpp | 330 ++++++ src/android/sudachi/src/main/jni/config.h | 47 + .../sudachi/src/main/jni/default_ini.h | 511 ++++++++ .../src/main/jni/emu_window/emu_window.cpp | 62 + src/android/sudachi/src/main/jni/id_cache.cpp | 428 +++++++ .../sudachi/src/main/jni/native_config.cpp | 543 +++++++++ .../sudachi/src/main/jni/native_input.cpp | 638 ++++++++++ .../ic_stat_notification_logo.png | Bin 0 -> 46179 bytes .../ic_stat_notification_logo.png | Bin 0 -> 48264 bytes .../src/main/res/drawable-xhdpi/tv_banner.png | Bin 0 -> 7764 bytes .../ic_stat_notification_logo.png | Bin 0 -> 56651 bytes .../main/res/drawable/button_l3_depressed.xml | 75 ++ .../src/main/res/drawable/button_r3.xml | 128 ++ .../src/main/res/drawable/dpad_standard.xml | 24 + .../src/main/res/drawable/facebutton_a.xml | 22 + .../res/drawable/facebutton_a_depressed.xml | 8 + .../drawable/facebutton_home_depressed.xml | 8 + .../main/res/drawable/facebutton_minus.xml | 22 + .../drawable/facebutton_minus_depressed.xml | 9 + .../src/main/res/drawable/facebutton_plus.xml | 22 + .../drawable/facebutton_plus_depressed.xml | 9 + .../facebutton_screenshot_depressed.xml | 8 + .../src/main/res/drawable/facebutton_x.xml | 22 + .../src/main/res/drawable/facebutton_y.xml | 22 + .../main/res/drawable/ic_arrow_forward.xml | 10 + .../src/main/res/drawable/ic_check_circle.xml | 9 + .../src/main/res/drawable/ic_clear.xml | 9 + .../sudachi/src/main/res/drawable/ic_code.xml | 9 + .../src/main/res/drawable/ic_controller.xml | 9 + .../drawable/ic_controller_disconnected.xml | 9 + .../src/main/res/drawable/ic_delete.xml | 9 + .../src/main/res/drawable/ic_diamond.xml | 9 + .../sudachi/src/main/res/drawable/ic_exit.xml | 10 + .../src/main/res/drawable/ic_export.xml | 9 + .../src/main/res/drawable/ic_folder_open.xml | 9 + .../src/main/res/drawable/ic_github.xml | 10 + .../src/main/res/drawable/ic_graphics.xml | 9 + .../src/main/res/drawable/ic_icon_bg.xml | 751 ++++++++++++ .../src/main/res/drawable/ic_install.xml | 9 + .../sudachi/src/main/res/drawable/ic_key.xml | 9 + .../src/main/res/drawable/ic_launcher.xml | 6 + .../sudachi/src/main/res/drawable/ic_lock.xml | 9 + .../sudachi/src/main/res/drawable/ic_nfc.xml | 9 + .../src/main/res/drawable/ic_overlay.xml | 21 + .../src/main/res/drawable/ic_pause.xml | 9 + .../src/main/res/drawable/ic_pip_play.xml | 9 + .../sudachi/src/main/res/drawable/ic_play.xml | 9 + .../src/main/res/drawable/ic_restore.xml | 9 + .../src/main/res/drawable/ic_settings.xml | 9 + .../main/res/drawable/ic_sudachi_title.xml | 24 + .../main/res/drawable/ic_system_settings.xml | 9 + .../src/main/res/drawable/ic_website.xml | 9 + .../src/main/res/drawable/joystick.xml | 45 + .../src/main/res/drawable/joystick_range.xml | 38 + .../src/main/res/drawable/l_shoulder.xml | 23 + .../res/drawable/l_shoulder_depressed.xml | 8 + .../main/res/drawable/premium_background.xml | 9 + .../res/drawable/zl_trigger_depressed.xml | 10 + .../res/layout-w600dp/fragment_game_info.xml | 155 +++ .../main/res/layout/card_applet_option.xml | 57 + .../main/res/layout/card_driver_option.xml | 81 ++ .../sudachi/src/main/res/layout/card_game.xml | 55 + .../src/main/res/layout/card_home_option.xml | 78 ++ .../src/main/res/layout/card_installable.xml | 72 ++ .../main/res/layout/card_installable_icon.xml | 89 ++ .../main/res/layout/card_simple_outlined.xml | 72 ++ .../src/main/res/layout/dialog_add_folder.xml | 45 + .../res/layout/dialog_folder_properties.xml | 30 + .../main/res/layout/dialog_input_profiles.xml | 6 + .../src/main/res/layout/dialog_license.xml | 64 + .../src/main/res/layout/dialog_list.xml | 15 + .../src/main/res/layout/dialog_mapping.xml | 26 + .../src/main/res/layout/dialog_slider.xml | 30 + .../res/layout/fragment_applet_launcher.xml | 33 + .../main/res/layout/fragment_early_access.xml | 245 ++++ .../main/res/layout/fragment_emulation.xml | 186 +++ .../src/main/res/layout/fragment_folders.xml | 51 + .../src/main/res/layout/fragment_games.xml | 35 + .../main/res/layout/fragment_installables.xml | 33 + .../src/main/res/layout/fragment_search.xml | 184 +++ .../src/main/res/layout/fragment_settings.xml | 43 + .../src/main/res/layout/list_item_addon.xml | 69 ++ .../res/layout/list_item_setting_switch.xml | 72 ++ .../res/layout/list_item_settings_header.xml | 16 + .../src/main/res/layout/page_setup.xml | 92 ++ .../src/main/res/menu/menu_driver_manager.xml | 8 + .../main/res/menu/menu_overlay_options.xml | 55 + .../res/navigation/settings_navigation.xml | 32 + .../src/main/res/values-ar/strings.xml | 467 ++++++++ .../src/main/res/values-cs/strings.xml | 264 +++++ .../src/main/res/values-de/strings.xml | 474 ++++++++ .../src/main/res/values-fa/strings.xml | 595 ++++++++++ .../src/main/res/values-hu/strings.xml | 627 ++++++++++ .../src/main/res/values-id/strings.xml | 587 +++++++++ .../src/main/res/values-nb/strings.xml | 399 +++++++ .../src/main/res/values-night-v31/themes.xml | 31 + .../src/main/res/values-night/themes.xml | 9 + .../src/main/res/values-pl/strings.xml | 394 +++++++ .../src/main/res/values-pt-rBR/strings.xml | 646 ++++++++++ .../src/main/res/values-pt-rPT/strings.xml | 646 ++++++++++ .../src/main/res/values-uk/strings.xml | 321 +++++ .../src/main/res/values-v31/themes.xml | 31 + .../src/main/res/values-vi/strings.xml | 401 +++++++ .../src/main/res/values-w600dp/dimens.xml | 7 + .../src/main/res/values-zh-rTW/strings.xml | 641 ++++++++++ .../sudachi/src/main/res/values/arrays.xml | 306 +++++ .../sudachi/src/main/res/values/dimens.xml | 23 + .../sudachi/src/main/res/values/integers.xml | 113 ++ .../sudachi/src/main/res/values/styles.xml | 36 + .../sudachi/src/main/res/values/themes.xml | 51 + .../main/res/xml/data_extraction_rules.xml | 20 + .../src/main/res/xml/game_mode_config.xml | 7 + .../src/main/res/xml/locales_config.xml | 17 + 210 files changed, 23849 insertions(+) create mode 100644 src/android/sudachi/proguard-rules.pro create mode 100644 src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml create mode 100644 src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml create mode 100644 src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml create mode 100644 src/android/sudachi/src/main/AndroidManifest.xml create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt create mode 100644 src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt create mode 100644 src/android/sudachi/src/main/jni/CMakeLists.txt create mode 100644 src/android/sudachi/src/main/jni/android_common/android_common.cpp create mode 100644 src/android/sudachi/src/main/jni/android_common/android_common.h create mode 100644 src/android/sudachi/src/main/jni/android_config.cpp create mode 100644 src/android/sudachi/src/main/jni/android_config.h create mode 100644 src/android/sudachi/src/main/jni/android_settings.cpp create mode 100644 src/android/sudachi/src/main/jni/config.cpp create mode 100644 src/android/sudachi/src/main/jni/config.h create mode 100644 src/android/sudachi/src/main/jni/default_ini.h create mode 100644 src/android/sudachi/src/main/jni/emu_window/emu_window.cpp create mode 100644 src/android/sudachi/src/main/jni/id_cache.cpp create mode 100644 src/android/sudachi/src/main/jni/native_config.cpp create mode 100644 src/android/sudachi/src/main/jni/native_input.cpp create mode 100644 src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png create mode 100644 src/android/sudachi/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png create mode 100644 src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png create mode 100644 src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png create mode 100644 src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/button_r3.xml create mode 100644 src/android/sudachi/src/main/res/drawable/dpad_standard.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_a.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_minus.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_plus.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_x.xml create mode 100644 src/android/sudachi/src/main/res/drawable/facebutton_y.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_check_circle.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_clear.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_code.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_controller.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_delete.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_diamond.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_exit.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_export.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_folder_open.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_github.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_graphics.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_install.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_key.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_launcher.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_lock.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_nfc.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_overlay.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_pause.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_pip_play.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_play.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_restore.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_settings.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_system_settings.xml create mode 100644 src/android/sudachi/src/main/res/drawable/ic_website.xml create mode 100644 src/android/sudachi/src/main/res/drawable/joystick.xml create mode 100644 src/android/sudachi/src/main/res/drawable/joystick_range.xml create mode 100644 src/android/sudachi/src/main/res/drawable/l_shoulder.xml create mode 100644 src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml create mode 100644 src/android/sudachi/src/main/res/drawable/premium_background.xml create mode 100644 src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml create mode 100644 src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_applet_option.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_driver_option.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_game.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_home_option.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_installable.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_installable_icon.xml create mode 100644 src/android/sudachi/src/main/res/layout/card_simple_outlined.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_add_folder.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_folder_properties.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_input_profiles.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_license.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_list.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_mapping.xml create mode 100644 src/android/sudachi/src/main/res/layout/dialog_slider.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_applet_launcher.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_early_access.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_emulation.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_folders.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_games.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_installables.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_search.xml create mode 100644 src/android/sudachi/src/main/res/layout/fragment_settings.xml create mode 100644 src/android/sudachi/src/main/res/layout/list_item_addon.xml create mode 100644 src/android/sudachi/src/main/res/layout/list_item_setting_switch.xml create mode 100644 src/android/sudachi/src/main/res/layout/list_item_settings_header.xml create mode 100644 src/android/sudachi/src/main/res/layout/page_setup.xml create mode 100644 src/android/sudachi/src/main/res/menu/menu_driver_manager.xml create mode 100644 src/android/sudachi/src/main/res/menu/menu_overlay_options.xml create mode 100644 src/android/sudachi/src/main/res/navigation/settings_navigation.xml create mode 100644 src/android/sudachi/src/main/res/values-ar/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-cs/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-de/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-fa/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-hu/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-id/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-nb/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-night-v31/themes.xml create mode 100644 src/android/sudachi/src/main/res/values-night/themes.xml create mode 100644 src/android/sudachi/src/main/res/values-pl/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-pt-rBR/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-pt-rPT/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-uk/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-v31/themes.xml create mode 100644 src/android/sudachi/src/main/res/values-vi/strings.xml create mode 100644 src/android/sudachi/src/main/res/values-w600dp/dimens.xml create mode 100644 src/android/sudachi/src/main/res/values-zh-rTW/strings.xml create mode 100644 src/android/sudachi/src/main/res/values/arrays.xml create mode 100644 src/android/sudachi/src/main/res/values/dimens.xml create mode 100644 src/android/sudachi/src/main/res/values/integers.xml create mode 100644 src/android/sudachi/src/main/res/values/styles.xml create mode 100644 src/android/sudachi/src/main/res/values/themes.xml create mode 100644 src/android/sudachi/src/main/res/xml/data_extraction_rules.xml create mode 100644 src/android/sudachi/src/main/res/xml/game_mode_config.xml create mode 100644 src/android/sudachi/src/main/res/xml/locales_config.xml diff --git a/src/android/sudachi/proguard-rules.pro b/src/android/sudachi/proguard-rules.pro new file mode 100644 index 0000000..3c6e455 --- /dev/null +++ b/src/android/sudachi/proguard-rules.pro @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2023 sudachi Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +# To get usable stack traces +-dontobfuscate + +# Prevents crashing when using Wini +-keep class org.ini4j.spi.IniParser +-keep class org.ini4j.spi.IniBuilder +-keep class org.ini4j.spi.IniFormatter + +# Suppress warnings for R8 +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE +-dontwarn java.beans.Introspector +-dontwarn java.beans.VetoableChangeListener +-dontwarn java.beans.VetoableChangeSupport diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml new file mode 100644 index 0000000..deb8ba5 --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml new file mode 100644 index 0000000..4ef4728 --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..29d0cfc --- /dev/null +++ b/src/android/sudachi/src/ea/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/sudachi/src/main/AndroidManifest.xml b/src/android/sudachi/src/main/AndroidManifest.xml new file mode 100644 index 0000000..73638c0 --- /dev/null +++ b/src/android/sudachi/src/main/AndroidManifest.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt new file mode 100644 index 0000000..858b6ef --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/SudachiApplication.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.sudachi.sudachi_emu.features.input.NativeInput +import java.io.File +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.DocumentsTree +import org.sudachi.sudachi_emu.utils.GpuDriverHelper +import org.sudachi.sudachi_emu.utils.Log + +fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir + +class SudachiApplication : Application() { + private fun createNotificationChannels() { + val noticeChannel = NotificationChannel( + getString(R.string.notice_notification_channel_id), + getString(R.string.notice_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH + ) + noticeChannel.description = getString(R.string.notice_notification_channel_description) + noticeChannel.setSound(null, null) + + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(noticeChannel) + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + DirectoryInitialization.start() + GpuDriverHelper.initializeDriverParameters() + NativeInput.reloadInputDevices() + NativeLibrary.logDeviceInfo() + Log.logDeviceInfo() + + createNotificationChannels() + } + + companion object { + var documentsTree: DocumentsTree? = null + lateinit var application: SudachiApplication + + val appContext: Context + get() = application.applicationContext + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt new file mode 100644 index 0000000..59a14ea --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/activities/EmulationActivity.kt @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.activities + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.graphics.Rect +import android.graphics.drawable.Icon +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.ActivityEmulationBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.Log +import org.sudachi.sudachi_emu.utils.MemoryUtil +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.NfcReader +import org.sudachi.sudachi_emu.utils.ParamPackage +import org.sudachi.sudachi_emu.utils.ThemeHelper +import java.text.NumberFormat +import kotlin.math.roundToInt + +class EmulationActivity : AppCompatActivity(), SensorEventListener { + private lateinit var binding: ActivityEmulationBinding + + var isActivityRecreated = false + private lateinit var nfcReader: NfcReader + + private val gyro = FloatArray(3) + private val accel = FloatArray(3) + private var motionTimestamp: Long = 0 + private var flipMotionOrientation: Boolean = false + + private val actionPause = "ACTION_EMULATOR_PAUSE" + private val actionPlay = "ACTION_EMULATOR_PLAY" + private val actionMute = "ACTION_EMULATOR_MUTE" + private val actionUnmute = "ACTION_EMULATOR_UNMUTE" + + private val emulationViewModel: EmulationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + Log.gameLaunched = true + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + InputHandler.updateControllerData() + val players = NativeConfig.getInputSettings(true) + var hasConfiguredControllers = false + players.forEach { + if (it.hasMapping()) { + hasConfiguredControllers = true + } + } + if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) { + var params: ParamPackage? = null + for (controller in InputHandler.registeredControllers) { + if (controller.get("port", -1) == 0) { + params = controller + break + } + } + + if (params != null) { + NativeInput.updateMappingsWithDefault( + 0, + params, + params.get("display", getString(R.string.unknown)) + ) + NativeConfig.saveGlobalConfig() + } + } + + binding = ActivityEmulationBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) + + isActivityRecreated = savedInstanceState != null + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + window.decorView.setBackgroundColor(getColor(android.R.color.black)) + + nfcReader = NfcReader(this) + nfcReader.initialize() + + val preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) { + if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) { + Toast.makeText( + this, + getString( + R.string.device_memory_inadequate, + MemoryUtil.getDeviceRAM(), + getString( + R.string.memory_formatted, + NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY), + getString(R.string.memory_gigabyte) + ) + ), + Toast.LENGTH_LONG + ).show() + preferences.edit() + .putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true) + .apply() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // Special case, we do not support multiline input, dismiss the keyboard. + val overlayView: View = + this.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + im.hideSoftInputFromWindow(overlayView.windowToken, 0) + } else { + val textChar = event.unicodeChar + if (textChar == 0) { + // No text, button input. + NativeLibrary.submitInlineKeyboardInput(keyCode) + } else { + // Text submitted. + NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString()) + } + } + } + return super.onKeyDown(keyCode, event) + } + + override fun onResume() { + super.onResume() + nfcReader.startScanning() + startMotionSensorListener() + InputHandler.updateControllerData() + + buildPictureInPictureParams() + } + + override fun onPause() { + super.onPause() + nfcReader.stopScanning() + stopMotionSensorListener() + } + + override fun onUserLeaveHint() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + enterPictureInPictureMode(pictureInPictureParamsBuilder.build()) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + nfcReader.onNewIntent(intent) + InputHandler.updateControllerData() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchKeyEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchKeyEvent(event) + } + + return InputHandler.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchGenericMotionEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchGenericMotionEvent(event) + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + return true + } + + return InputHandler.dispatchGenericMotionEvent(event) + } + + override fun onSensorChanged(event: SensorEvent) { + val rotation = this.display?.rotation + if (rotation == Surface.ROTATION_90) { + flipMotionOrientation = true + } + if (rotation == Surface.ROTATION_270) { + flipMotionOrientation = false + } + + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { + if (flipMotionOrientation) { + accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH + } else { + accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH + } + accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH + } + if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { + // Investigate why sensor value is off by 6x + if (flipMotionOrientation) { + gyro[0] = -event.values[1] / 6.0f + gyro[1] = event.values[0] / 6.0f + } else { + gyro[0] = event.values[1] / 6.0f + gyro[1] = -event.values[0] / 6.0f + } + gyro[2] = event.values[2] / 6.0f + } + + // Only update state on accelerometer data + if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { + return + } + val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 + motionTimestamp = event.timestamp + NativeInput.onDeviceMotionEvent( + NativeInput.Player1Device, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + NativeInput.onDeviceMotionEvent( + NativeInput.ConsoleDevice, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + } + + override fun onAccuracyChanged(sensor: Sensor, i: Int) {} + + private fun enableFullscreenImmersive() { + WindowCompat.setDecorFitsSystemWindows(window, false) + + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder(): + PictureInPictureParams.Builder { + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + return this.apply { aspectRatio?.let { setAspectRatio(it) } } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder(): + PictureInPictureParams.Builder { + val pictureInPictureActions: MutableList = mutableListOf() + val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + if (NativeLibrary.isPaused()) { + val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play) + val playPendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_play, + Intent(actionPlay), + pendingFlags + ) + val playRemoteAction = RemoteAction( + playIcon, + getString(R.string.play), + getString(R.string.play), + playPendingIntent + ) + pictureInPictureActions.add(playRemoteAction) + } else { + val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause) + val pausePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_pause, + Intent(actionPause), + pendingFlags + ) + val pauseRemoteAction = RemoteAction( + pauseIcon, + getString(R.string.pause), + getString(R.string.pause), + pausePendingIntent + ) + pictureInPictureActions.add(pauseRemoteAction) + } + + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + val unmuteIcon = Icon.createWithResource( + this@EmulationActivity, + R.drawable.ic_pip_unmute + ) + val unmutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_unmute, + Intent(actionUnmute), + pendingFlags + ) + val unmuteRemoteAction = RemoteAction( + unmuteIcon, + getString(R.string.unmute), + getString(R.string.unmute), + unmutePendingIntent + ) + pictureInPictureActions.add(unmuteRemoteAction) + } else { + val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute) + val mutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_mute, + Intent(actionMute), + pendingFlags + ) + val muteRemoteAction = RemoteAction( + muteIcon, + getString(R.string.mute), + getString(R.string.mute), + mutePendingIntent + ) + pictureInPictureActions.add(muteRemoteAction) + } + + return this.apply { setActions(pictureInPictureActions) } + } + + fun buildPictureInPictureParams() { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val isEmulationActive = emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + pictureInPictureParamsBuilder.setAutoEnterEnabled( + BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive + ) + } + setPictureInPictureParams(pictureInPictureParamsBuilder.build()) + } + + private var pictureInPictureReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (intent.action == actionPlay) { + if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation() + } else if (intent.action == actionPause) { + if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation() + } + if (intent.action == actionUnmute) { + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } else if (intent.action == actionMute) { + if (!BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(true) + } + } + buildPictureInPictureParams() + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + IntentFilter().apply { + addAction(actionPause) + addAction(actionPlay) + addAction(actionMute) + addAction(actionUnmute) + }.also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED) + } else { + registerReceiver(pictureInPictureReceiver, it) + } + } + } else { + try { + unregisterReceiver(pictureInPictureReceiver) + } catch (ignored: Exception) { + } + // Always resume audio, since there is no UI button + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } + } + + fun onEmulationStarted() { + emulationViewModel.setEmulationStarted(true) + } + + fun onEmulationStopped(status: Int) { + if (status == 0 && emulationViewModel.programChanged.value == -1) { + finish() + } + emulationViewModel.setEmulationStopped(true) + } + + fun onProgramChanged(programIndex: Int) { + emulationViewModel.setProgramChanged(programIndex) + } + + private fun startMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME) + sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME) + } + + private fun stopMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + sensorManager.unregisterListener(this, gyroSensor) + sensorManager.unregisterListener(this, accelSensor) + } + + companion object { + const val EXTRA_SELECTED_GAME = "SelectedGame" + + fun launch(activity: AppCompatActivity, game: Game) { + val launcher = Intent(activity, EmulationActivity::class.java) + launcher.putExtra(EXTRA_SELECTED_GAME, game) + activity.startActivity(launcher) + } + + private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean { + if (view == null) { + return true + } + val viewBounds = Rect() + view.getGlobalVisibleRect(viewBounds) + return !viewBounds.contains(x.roundToInt(), y.roundToInt()) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt new file mode 100644 index 0000000..b145080 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AbstractDiffAdapter.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder +import androidx.recyclerview.widget.RecyclerView + +/** + * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate + * code used in every [RecyclerView]. + * Type assigned to [Model] must inherit from [Object] in order to be compared properly. + * @param exact Decides whether each item will be compared by reference or by their contents + */ +abstract class AbstractDiffAdapter>( + exact: Boolean = true +) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + if (exact) { + return oldItem === newItem + } + return oldItem == newItem + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt new file mode 100644 index 0000000..92824b2 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AddonAdapter.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.sudachi.sudachi_emu.databinding.ListItemAddonBinding +import org.sudachi.sudachi_emu.model.Patch +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class AddonAdapter(val addonViewModel: AddonViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { + ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AddonViewHolder(it) } + } + + inner class AddonViewHolder(val binding: ListItemAddonBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Patch) { + binding.root.setOnClickListener { + binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked + } + binding.title.text = model.name + binding.version.text = model.version + binding.addonCheckbox.setOnCheckedChangeListener { _, checked -> + model.enabled = checked + } + binding.addonCheckbox.isChecked = model.enabled + binding.buttonDelete.setOnClickListener { + addonViewModel.setAddonToDelete(model) + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt new file mode 100644 index 0000000..10db1f7 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/AppletAdapter.kt @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.FragmentActivity +import androidx.navigation.findNavController +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.CardSimpleOutlinedBinding +import org.sudachi.sudachi_emu.model.Applet +import org.sudachi.sudachi_emu.model.AppletInfo +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class AppletAdapter(val activity: FragmentActivity, applets: List) : + AbstractListAdapter(applets) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AppletAdapter.AppletViewHolder { + CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AppletViewHolder(it) } + } + + inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Applet) { + binding.title.setText(model.titleId) + binding.description.setText(model.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + model.iconId, + binding.icon.context.theme + ) + ) + + binding.root.setOnClickListener { onClick(model) } + } + + fun onClick(applet: Applet) { + val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId) + if (appletPath.isEmpty()) { + Toast.makeText( + binding.root.context, + R.string.applets_error_applet, + Toast.LENGTH_SHORT + ).show() + return + } + + if (applet.appletInfo == AppletInfo.Cabinet) { + binding.root.findNavController() + .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment) + return + } + + NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId) + val appletGame = Game( + title = SudachiApplication.appContext.getString(applet.titleId), + path = appletPath + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) + binding.root.findNavController().navigate(action) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt new file mode 100644 index 0000000..703280d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/FolderAdapter.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import org.sudachi.sudachi_emu.databinding.CardFolderBinding +import org.sudachi.sudachi_emu.fragments.GameFolderPropertiesDialogFragment +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): FolderAdapter.FolderViewHolder { + CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return FolderViewHolder(it) } + } + + inner class FolderViewHolder(val binding: CardFolderBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameDir) { + binding.apply { + path.text = Uri.parse(model.uriString).path + path.marquee() + + buttonEdit.setOnClickListener { + GameFolderPropertiesDialogFragment.newInstance(model) + .show( + activity.supportFragmentManager, + GameFolderPropertiesDialogFragment.TAG + ) + } + + buttonDelete.setOnClickListener { + gamesViewModel.removeFolder(model) + } + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt new file mode 100644 index 0000000..b396326 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/GameAdapter.kt @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.databinding.CardGameBinding +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.GameIconUtils +import org.sudachi.sudachi_emu.utils.ViewUtils.marquee +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class GameAdapter(private val activity: AppCompatActivity) : + AbstractDiffAdapter(exact = false) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return GameViewHolder(it) } + } + + inner class GameViewHolder(val binding: CardGameBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Game) { + binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, binding.imageGameScreen) + + binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + binding.textGameTitle.marquee() + binding.cardGame.setOnClickListener { onClick(model) } + binding.cardGame.setOnLongClickListener { onLongClick(model) } + } + + fun onClick(game: Game) { + val gameExists = DocumentFile.fromSingleUri( + SudachiApplication.appContext, + Uri.parse(game.path) + )?.exists() == true + if (!gameExists) { + Toast.makeText( + SudachiApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + return + } + + val preferences = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + preferences.edit() + .putLong( + game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + activity.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val shortcut = + ShortcutInfoCompat.Builder(SudachiApplication.appContext, game.path) + .setShortLabel(game.title) + .setIcon(GameIconUtils.getShortcutIcon(activity, game)) + .setIntent(game.launchIntent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(SudachiApplication.appContext, shortcut) + } + } + + val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true) + binding.root.findNavController().navigate(action) + } + + fun onLongClick(game: Game): Boolean { + val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game) + binding.root.findNavController().navigate(action) + return true + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt new file mode 100644 index 0000000..dd06d2e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/LicenseAdapter.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.fragments.LicenseBottomSheetDialogFragment +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class LicenseAdapter(private val activity: AppCompatActivity, licenses: List) : + AbstractListAdapter(licenses) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { + ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return LicenseViewHolder(it) } + } + + inner class LicenseViewHolder(val binding: ListItemSettingBinding) : + AbstractViewHolder(binding) { + override fun bind(model: License) { + binding.apply { + textSettingName.text = root.context.getString(model.titleId) + textSettingDescription.text = root.context.getString(model.descriptionId) + textSettingValue.setVisible(false) + + root.setOnClickListener { onClick(model) } + } + } + + private fun onClick(license: License) { + LicenseBottomSheetDialogFragment.newInstance(license) + .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt new file mode 100644 index 0000000..313ae21 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/adapters/SetupAdapter.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.adapters + +import android.text.Html +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.button.MaterialButton +import org.sudachi.sudachi_emu.databinding.PageSetupBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.SetupCallback +import org.sudachi.sudachi_emu.model.SetupPage +import org.sudachi.sudachi_emu.model.StepState +import org.sudachi.sudachi_emu.utils.ViewUtils +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.viewholder.AbstractViewHolder + +class SetupAdapter(val activity: AppCompatActivity, pages: List) : + AbstractListAdapter(pages) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { + PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return SetupPageViewHolder(it) } + } + + inner class SetupPageViewHolder(val binding: PageSetupBinding) : + AbstractViewHolder(binding), SetupCallback { + override fun bind(model: SetupPage) { + if (model.stepCompleted.invoke() == StepState.COMPLETE) { + binding.buttonAction.setVisible(visible = false, gone = false) + binding.textConfirmation.setVisible(true) + } + + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + model.iconId, + activity.theme + ) + ) + binding.textTitle.text = activity.resources.getString(model.titleId) + binding.textDescription.text = + Html.fromHtml(activity.resources.getString(model.descriptionId), 0) + + binding.buttonAction.apply { + text = activity.resources.getString(model.buttonTextId) + if (model.buttonIconId != 0) { + icon = ResourcesCompat.getDrawable( + activity.resources, + model.buttonIconId, + activity.theme + ) + } + iconGravity = + if (model.leftAlignedIcon) { + MaterialButton.ICON_GRAVITY_START + } else { + MaterialButton.ICON_GRAVITY_END + } + setOnClickListener { + model.buttonAction.invoke(this@SetupPageViewHolder) + } + } + } + + override fun onStepCompleted() { + ViewUtils.hideView(binding.buttonAction, 200) + ViewUtils.showView(binding.textConfirmation, 200) + ViewModelProvider(activity)[HomeViewModel::class.java].setShouldPageForward(true) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100644 index 0000000..580014d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.disk_shader_cache + +import androidx.annotation.Keep +import androidx.lifecycle.ViewModelProvider +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.utils.Log + +@Keep +object DiskShaderCacheProgress { + private lateinit var emulationViewModel: EmulationViewModel + + private fun prepareViewModel() { + emulationViewModel = + ViewModelProvider( + NativeLibrary.sEmulationActivity.get() as EmulationActivity + )[EmulationViewModel::class.java] + } + + @JvmStatic + fun loadProgress(stage: Int, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present") + return + } + + emulationActivity.runOnUiThread { + when (LoadCallbackStage.values()[stage]) { + LoadCallbackStage.Prepare -> prepareViewModel() + LoadCallbackStage.Build -> emulationViewModel.updateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + + LoadCallbackStage.Complete -> {} + } + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, Build, Complete + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt new file mode 100644 index 0000000..52288d9 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/NativeInput.kt @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input + +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.ButtonName +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.ParamPackage +import android.view.InputDevice + +object NativeInput { + /** + * Default controller id for each device + */ + const val Player1Device = 0 + const val Player2Device = 1 + const val Player3Device = 2 + const val Player4Device = 3 + const val Player5Device = 4 + const val Player6Device = 5 + const val Player7Device = 6 + const val Player8Device = 7 + const val ConsoleDevice = 8 + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } + + /** + * Returns true if pro controller isn't available and handheld is. + * Intended to check where the input overlay should direct its inputs. + */ + external fun isHandheldOnly(): Boolean + + /** + * Handles button press events for a gamepad. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param buttonId The Android Keycode corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + external fun onGamePadButtonEvent( + guid: String, + port: Int, + buttonId: Int, + action: Int + ) + + /** + * Handles axis movement events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param axis The axis ID. + * @param value Value along the given axis. + */ + external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float) + + /** + * Handles motion events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onGamePadMotionEvent( + guid: String, + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Signals and load a nfc tag + * @param data Byte array containing all the data from a nfc tag. + */ + external fun onReadNfcTag(data: ByteArray?) + + /** + * Removes current loaded nfc tag. + */ + external fun onRemoveNfcTag() + + /** + * Handles touch press events. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch movement. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch release events. + * @param fingerId The finger id corresponding to this event + */ + external fun onTouchReleased(fingerId: Int) + + /** + * Sends a button input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param button The [NativeButton] corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) = + onOverlayButtonEventImpl(port, button.int, action) + + private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int) + + /** + * Sends a joystick input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param stick The [NativeAnalog] corresponding to this event. + * @param xAxis Value along the X axis. + * @param yAxis Value along the Y axis. + */ + fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) = + onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis) + + private external fun onOverlayJoystickEventImpl( + port: Int, + stickId: Int, + xAxis: Float, + yAxis: Float + ) + + /** + * Handles motion events for the global virtual controllers. + * @param port Port determined by controller connection order + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onDeviceMotionEvent( + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Reloads all input devices from the currently loaded Settings::values.players into HID Core + */ + external fun reloadInputDevices() + + /** + * Registers a controller to be used with mapping + * @param device An [InputDevice] or the input overlay wrapped with [SudachiInputDevice] + */ + external fun registerController(device: SudachiInputDevice) + + /** + * Gets the names of input devices that have been registered with the input subsystem via [registerController] + */ + external fun getInputDevices(): Array + + /** + * Reads all input profiles from disk. Must be called before creating a profile picker. + */ + external fun loadInputProfiles() + + /** + * Gets the names of each available input profile. + */ + external fun getInputProfileNames(): Array + + /** + * Checks if the user-provided name for an input profile is valid. + * @param name User-provided name for an input profile. + * @return Whether [name] is valid or not. + */ + external fun isProfileNameValid(name: String): Boolean + + /** + * Creates a new input profile. + * @param name The new profile's name. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether creating the profile was successful or not. + */ + external fun createProfile(name: String, playerIndex: Int): Boolean + + /** + * Deletes an input profile. + * @param name Name of the profile to delete. + * @param playerIndex Index of the player that's currently being edited. Used to remove the profile + * name from this player's config if they have it loaded. + * @return Whether deleting this profile was successful or not. + */ + external fun deleteProfile(name: String, playerIndex: Int): Boolean + + /** + * Loads an input profile. + * @param name Name of the input profile to load. + * @param playerIndex Index of the player that will have this profile loaded. + * @return Whether loading this profile was successful or not. + */ + external fun loadProfile(name: String, playerIndex: Int): Boolean + + /** + * Saves an input profile. + * @param name Name of the profile to save. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether saving the profile was successful or not. + */ + external fun saveProfile(name: String, playerIndex: Int): Boolean + + /** + * Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues] + * Must be used while per-game config is loaded. + */ + external fun loadPerGameConfiguration( + playerIndex: Int, + selectedIndex: Int, + selectedProfileName: String + ) + + /** + * Tells the input subsystem to start listening for inputs to map. + * @param type Type of input to map as shown by the int property in each [InputType]. + */ + external fun beginMapping(type: Int) + + /** + * Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping. + * Must be run after [beginMapping] and before [stopMapping]. + */ + external fun getNextInput(): String + + /** + * Tells the input subsystem to stop listening for inputs to map. + */ + external fun stopMapping() + + /** + * Updates a controller's mappings with auto-mapping params. + * @param playerIndex Index of the player to auto-map. + * @param deviceParams [ParamPackage] representing the device to auto-map as received + * from [getInputDevices]. + * @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams]. + * Intended to be a way to provide a default name for a controller if the "display" param is empty. + */ + fun updateMappingsWithDefault( + playerIndex: Int, + deviceParams: ParamPackage, + displayName: String + ) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName) + + private external fun updateMappingsWithDefaultImpl( + playerIndex: Int, + deviceParams: String, + displayName: String + ) + + /** + * Gets the params for a specific button. + * @param playerIndex Index of the player to get params from. + * @param button The [NativeButton] to get params for. + * @return A [ParamPackage] representing a player's specific button. + */ + fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage = + ParamPackage(getButtonParamImpl(playerIndex, button.int)) + + private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String + + /** + * Sets the params for a specific button. + * @param playerIndex Index of the player to set params for. + * @param button The [NativeButton] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) = + setButtonParamImpl(playerIndex, button.int, param.serialize()) + + private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String) + + /** + * Gets the params for a specific stick. + * @param playerIndex Index of the player to get params from. + * @param stick The [NativeAnalog] to get params for. + * @return A [ParamPackage] representing a player's specific stick. + */ + fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage = + ParamPackage(getStickParamImpl(playerIndex, stick.int)) + + private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String + + /** + * Sets the params for a specific stick. + * @param playerIndex Index of the player to set params for. + * @param stick The [NativeAnalog] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) = + setStickParamImpl(playerIndex, stick.int, param.serialize()) + + private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String) + + /** + * Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for + * a button/analog/other. + * @param param A [ParamPackage] that represents a specific button's params. + * @return The [ButtonName] for [param]. + */ + fun getButtonName(param: ParamPackage): ButtonName = + ButtonName.from(getButtonNameImpl(param.serialize())) + + private external fun getButtonNameImpl(param: String): Int + + /** + * Gets each supported [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get supported indexes for. + * @return List of each supported [NpadStyleIndex]. + */ + fun getSupportedStyleTags(playerIndex: Int): List = + getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) } + + private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray + + /** + * Gets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get an [NpadStyleIndex] from. + * @return The [NpadStyleIndex] for a given player. + */ + fun getStyleIndex(playerIndex: Int): NpadStyleIndex = + NpadStyleIndex.from(getStyleIndexImpl(playerIndex)) + + private external fun getStyleIndexImpl(playerIndex: Int): Int + + /** + * Sets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to change. + * @param style The new style to set. + */ + fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) = + setStyleIndexImpl(playerIndex, style.int) + + private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int) + + /** + * Checks if a device is a controller. + * @param params [ParamPackage] for an input device retrieved from [getInputDevices] + * @return Whether the device is a controller or not. + */ + fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize()) + + private external fun isControllerImpl(params: String): Boolean + + /** + * Checks if a controller is connected + * @param playerIndex Index of the player to check. + * @return Whether the player is connected or not. + */ + external fun getIsConnected(playerIndex: Int): Boolean + + /** + * Connects/disconnects a controller and ensures that connection order stays in-tact. + * @param playerIndex Index of the player to connect/disconnect. + * @param connected Whether to connect or disconnect this controller. + */ + fun connectControllers(playerIndex: Int, connected: Boolean = true) { + val connectedControllers = mutableListOf().apply { + if (connected) { + for (i in 0 until 8) { + add(i <= playerIndex) + } + } else { + for (i in 0 until 8) { + add(i < playerIndex) + } + } + } + connectControllersImpl(connectedControllers.toBooleanArray()) + } + + private external fun connectControllersImpl(connected: BooleanArray) + + /** + * Resets all of the button and analog mappings for a player. + * @param playerIndex Index of the player that will have its mappings reset. + */ + external fun resetControllerMappings(playerIndex: Int) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt new file mode 100644 index 0000000..4f50bb0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/SudachiVibrator.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input + +import android.content.Context +import android.os.Build +import android.os.CombinedVibration +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +import org.sudachi.sudachi_emu.SudachiApplication + +@Keep +@Suppress("DEPRECATION") +interface SudachiVibrator { + fun supportsVibration(): Boolean + + fun vibrate(intensity: Float) + + companion object { + fun getControllerVibrator(device: InputDevice): SudachiVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + SudachiVibratorManager(device.vibratorManager) + } else { + SudachiVibratorManagerCompat(device.vibrator) + } + + fun getSystemVibrator(): SudachiVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = SudachiApplication.appContext + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + SudachiVibratorManager(vibratorManager) + } else { + val vibrator = SudachiApplication.appContext + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + SudachiVibratorManagerCompat(vibrator) + } + + fun getVibrationEffect(intensity: Float): VibrationEffect? { + if (intensity > 0f) { + return VibrationEffect.createOneShot( + 50, + (255.0 * intensity).toInt().coerceIn(1, 255) + ) + } + return null + } + } +} + +@RequiresApi(Build.VERSION_CODES.S) +class SudachiVibratorManager(private val vibratorManager: VibratorManager) : SudachiVibrator { + override fun supportsVibration(): Boolean { + return vibratorManager.vibratorIds.isNotEmpty() + } + + override fun vibrate(intensity: Float) { + val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return + vibratorManager.vibrate(CombinedVibration.createParallel(vibration)) + } +} + +class SudachiVibratorManagerCompat(private val vibrator: Vibrator) : SudachiVibrator { + override fun supportsVibration(): Boolean { + return vibrator.hasVibrator() + } + + override fun vibrate(intensity: Float) { + val vibration = SudachiVibrator.getVibrationEffect(intensity) ?: return + vibrator.vibrate(vibration) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt new file mode 100644 index 0000000..45d92d1 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/AnalogDirection.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +enum class AnalogDirection(val int: Int, val param: String) { + Up(0, "up"), + Down(1, "down"), + Left(2, "left"), + Right(3, "right") +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt new file mode 100644 index 0000000..2d8ff80 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/ButtonName.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Loosely matches the enum in common/input.h +enum class ButtonName(val int: Int) { + Invalid(1), + + // This will display the engine name instead of the button name + Engine(2), + + // This will display the button by value instead of the button name + Value(3); + + companion object { + fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt new file mode 100644 index 0000000..88ef792 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NativeAnalog.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeAnalog(val int: Int) { + LStick(0), + RStick(1); + + companion object { + fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt new file mode 100644 index 0000000..4d938fa --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/NpadStyleIndex.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R + +// Must match enum in src/core/hid/hid_types.h +enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) { + None(0), + Fullkey(3, R.string.pro_controller), + Handheld(4, R.string.handheld), + HandheldNES(4), + JoyconDual(5, R.string.dual_joycons), + JoyconLeft(6, R.string.left_joycon), + JoyconRight(7, R.string.right_joycon), + GameCube(8, R.string.gamecube_controller), + Pokeball(9), + NES(10), + SNES(12), + N64(13), + SegaGenesis(14), + SystemExt(32), + System(33); + + companion object { + fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt new file mode 100644 index 0000000..bd1328c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/input/model/PlayerInput.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.input.model + +import androidx.annotation.Keep + +@Keep +data class PlayerInput( + var connected: Boolean, + var buttons: Array, + var analogs: Array, + var motions: Array, + + var vibrationEnabled: Boolean, + var vibrationStrength: Int, + + var bodyColorLeft: Long, + var bodyColorRight: Long, + var buttonColorLeft: Long, + var buttonColorRight: Long, + var profileName: String, + + var useSystemVibrator: Boolean +) { + // It's recommended to use the generated equals() and hashCode() methods + // when using arrays in a data class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerInput + + if (connected != other.connected) return false + if (!buttons.contentEquals(other.buttons)) return false + if (!analogs.contentEquals(other.analogs)) return false + if (!motions.contentEquals(other.motions)) return false + if (vibrationEnabled != other.vibrationEnabled) return false + if (vibrationStrength != other.vibrationStrength) return false + if (bodyColorLeft != other.bodyColorLeft) return false + if (bodyColorRight != other.bodyColorRight) return false + if (buttonColorLeft != other.buttonColorLeft) return false + if (buttonColorRight != other.buttonColorRight) return false + if (profileName != other.profileName) return false + return useSystemVibrator == other.useSystemVibrator + } + + override fun hashCode(): Int { + var result = connected.hashCode() + result = 31 * result + buttons.contentHashCode() + result = 31 * result + analogs.contentHashCode() + result = 31 * result + motions.contentHashCode() + result = 31 * result + vibrationEnabled.hashCode() + result = 31 * result + vibrationStrength + result = 31 * result + bodyColorLeft.hashCode() + result = 31 * result + bodyColorRight.hashCode() + result = 31 * result + buttonColorLeft.hashCode() + result = 31 * result + buttonColorRight.hashCode() + result = 31 * result + profileName.hashCode() + result = 31 * result + useSystemVibrator.hashCode() + return result + } + + fun hasMapping(): Boolean { + var hasMapping = false + buttons.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + analogs.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + motions.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + return hasMapping + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100644 index 0000000..7dd7c0f --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractFloatSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractFloatSetting : AbstractSetting { + fun getFloat(needsGlobal: Boolean = false): Float + fun setFloat(value: Float) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt new file mode 100644 index 0000000..bc01b75 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractIntSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractIntSetting : AbstractSetting { + fun getInt(needsGlobal: Boolean = false): Int + fun setInt(value: Int) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt new file mode 100644 index 0000000..88213f7 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/AbstractStringSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +interface AbstractStringSetting : AbstractSetting { + fun getString(needsGlobal: Boolean = false): String + fun setString(value: String) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt new file mode 100644 index 0000000..49b9459 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/ByteSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class ByteSetting(override val key: String) : AbstractByteSetting { + AUDIO_VOLUME("volume"); + + override fun getByte(needsGlobal: Boolean): Byte = NativeConfig.getByte(key, needsGlobal) + + override fun setByte(value: Byte) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setByte(key, value) + } + + override val defaultValue: Byte by lazy { NativeConfig.getDefaultToString(key).toByte() } + + override fun getValueAsString(needsGlobal: Boolean): String = getByte(needsGlobal).toString() + + override fun reset() = NativeConfig.setByte(key, defaultValue) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt new file mode 100644 index 0000000..e7e1c29 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/FloatSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class FloatSetting(override val key: String) : AbstractFloatSetting { + // No float settings currently exist + EMPTY_SETTING(""); + + override fun getFloat(needsGlobal: Boolean): Float = NativeConfig.getFloat(key, false) + + override fun setFloat(value: Float) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setFloat(key, value) + } + + override val defaultValue: Float by lazy { NativeConfig.getDefaultToString(key).toFloat() } + + override fun getValueAsString(needsGlobal: Boolean): String = getFloat(needsGlobal).toString() + + override fun reset() = NativeConfig.setFloat(key, defaultValue) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt new file mode 100644 index 0000000..677d0e9 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/LongSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class LongSetting(override val key: String) : AbstractLongSetting { + CUSTOM_RTC("custom_rtc"); + + override fun getLong(needsGlobal: Boolean): Long = NativeConfig.getLong(key, needsGlobal) + + override fun setLong(value: Long) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setLong(key, value) + } + + override val defaultValue: Long by lazy { NativeConfig.getDefaultToString(key).toLong() } + + override fun getValueAsString(needsGlobal: Boolean): String = getLong(needsGlobal).toString() + + override fun reset() = NativeConfig.setLong(key, defaultValue) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt new file mode 100644 index 0000000..4965f73 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/Settings.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication + +object Settings { + enum class MenuTag(val titleId: Int = 0) { + SECTION_ROOT(R.string.advanced_settings), + SECTION_SYSTEM(R.string.preferences_system), + SECTION_RENDERER(R.string.preferences_graphics), + SECTION_AUDIO(R.string.preferences_audio), + SECTION_INPUT(R.string.preferences_controls), + SECTION_INPUT_PLAYER_ONE, + SECTION_INPUT_PLAYER_TWO, + SECTION_INPUT_PLAYER_THREE, + SECTION_INPUT_PLAYER_FOUR, + SECTION_INPUT_PLAYER_FIVE, + SECTION_INPUT_PLAYER_SIX, + SECTION_INPUT_PLAYER_SEVEN, + SECTION_INPUT_PLAYER_EIGHT, + SECTION_THEME(R.string.preferences_theme), + SECTION_DEBUG(R.string.preferences_debug); + } + + fun getPlayerString(player: Int): String = + SudachiApplication.appContext.getString(R.string.preferences_player, player) + + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" + const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" + + // Deprecated input overlay preference keys + const val PREF_CONTROL_SCALE = "controlScale" + const val PREF_CONTROL_OPACITY = "controlOpacity" + const val PREF_TOUCH_ENABLED = "isTouchEnabled" + const val PREF_BUTTON_A = "buttonToggle0" + const val PREF_BUTTON_B = "buttonToggle1" + const val PREF_BUTTON_X = "buttonToggle2" + const val PREF_BUTTON_Y = "buttonToggle3" + const val PREF_BUTTON_L = "buttonToggle4" + const val PREF_BUTTON_R = "buttonToggle5" + const val PREF_BUTTON_ZL = "buttonToggle6" + const val PREF_BUTTON_ZR = "buttonToggle7" + const val PREF_BUTTON_PLUS = "buttonToggle8" + const val PREF_BUTTON_MINUS = "buttonToggle9" + const val PREF_BUTTON_DPAD = "buttonToggle10" + const val PREF_STICK_L = "buttonToggle11" + const val PREF_STICK_R = "buttonToggle12" + const val PREF_BUTTON_STICK_L = "buttonToggle13" + const val PREF_BUTTON_STICK_R = "buttonToggle14" + const val PREF_BUTTON_HOME = "buttonToggle15" + const val PREF_BUTTON_SCREENSHOT = "buttonToggle16" + const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter" + const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable" + const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics" + const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" + const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" + val overlayPreferences = listOf( + PREF_BUTTON_A, + PREF_BUTTON_B, + PREF_BUTTON_X, + PREF_BUTTON_Y, + PREF_BUTTON_L, + PREF_BUTTON_R, + PREF_BUTTON_ZL, + PREF_BUTTON_ZR, + PREF_BUTTON_PLUS, + PREF_BUTTON_MINUS, + PREF_BUTTON_DPAD, + PREF_STICK_L, + PREF_STICK_R, + PREF_BUTTON_HOME, + PREF_BUTTON_SCREENSHOT, + PREF_BUTTON_STICK_L, + PREF_BUTTON_STICK_R + ) + + // Deprecated layout preference keys + const val PREF_LANDSCAPE_SUFFIX = "_Landscape" + const val PREF_PORTRAIT_SUFFIX = "_Portrait" + const val PREF_FOLDABLE_SUFFIX = "_Foldable" + val overlayLayoutSuffixes = listOf( + PREF_LANDSCAPE_SUFFIX, + PREF_PORTRAIT_SUFFIX, + PREF_FOLDABLE_SUFFIX + ) + + // Deprecated theme preference keys + const val PREF_THEME = "Theme" + const val PREF_THEME_MODE = "ThemeMode" + const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" + + enum class EmulationOrientation(val int: Int) { + Unspecified(0), + SensorLandscape(5), + Landscape(1), + ReverseLandscape(2), + SensorPortrait(6), + Portrait(4), + ReversePortrait(3); + + companion object { + fun from(int: Int): EmulationOrientation = + entries.firstOrNull { it.int == int } ?: Unspecified + } + } + + enum class EmulationVerticalAlignment(val int: Int) { + Top(1), + Center(0), + Bottom(2); + + companion object { + fun from(int: Int): EmulationVerticalAlignment = + entries.firstOrNull { it.int == int } ?: Center + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt new file mode 100644 index 0000000..29aea99 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/StringSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model + +import org.sudachi.sudachi_emu.utils.NativeConfig + +enum class StringSetting(override val key: String) : AbstractStringSetting { + DRIVER_PATH("driver_path"), + DEVICE_NAME("device_name"); + + override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal) + + override fun setString(value: String) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setString(key, value) + } + + override val defaultValue: String by lazy { NativeConfig.getDefaultToString(key) } + + override fun getValueAsString(needsGlobal: Boolean): String = getString(needsGlobal) + + override fun reset() = NativeConfig.setString(key, defaultValue) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt new file mode 100644 index 0000000..1e5b91d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/AnalogInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.utils.ParamPackage + +class AnalogInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + val analogDirection: AnalogDirection, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Stick + + override fun getSelectedValue(): String { + val params = NativeInput.getStickParam(playerIndex, nativeAnalog) + val analog = analogToText(params, analogDirection.param) + return getDisplayString(params, analog) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setStickParam(playerIndex, nativeAnalog, param) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100644 index 0000000..5a53371 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/DateTimeSetting.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractLongSetting + +class DateTimeSetting( + private val longSetting: AbstractLongSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(longSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_DATETIME_SETTING + + fun getValue(needsGlobal: Boolean = false): Long = longSetting.getLong(needsGlobal) + fun setValue(value: Long) = (setting as AbstractLongSetting).setLong(value) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt new file mode 100644 index 0000000..094a00d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/InputSetting.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.ButtonName +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.utils.ParamPackage + +sealed class InputSetting( + @StringRes titleId: Int, + titleString: String +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_INPUT + abstract val inputType: InputType + abstract val playerIndex: Int + + protected val context get() = SudachiApplication.appContext + + abstract fun getSelectedValue(): String + + abstract fun setSelectedValue(param: ParamPackage) + + protected fun getDisplayString(params: ParamPackage, control: String): String { + val deviceName = params.get("display", "") + deviceName.ifEmpty { + return context.getString(R.string.not_set) + } + return "$deviceName: $control" + } + + private fun getDirectionName(direction: String): String = + when (direction) { + "up" -> context.getString(R.string.up) + "down" -> context.getString(R.string.down) + "left" -> context.getString(R.string.left) + "right" -> context.getString(R.string.right) + else -> direction + } + + protected fun buttonToText(param: ParamPackage): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + val toggle = if (param.get("toggle", false)) "~" else "" + val inverted = if (param.get("inverted", false)) "!" else "" + val invert = if (param.get("invert", "+") == "-") "-" else "" + val turbo = if (param.get("turbo", false)) "$" else "" + val commonButtonName = NativeInput.getButtonName(param) + + if (commonButtonName == ButtonName.Invalid) { + return context.getString(R.string.invalid) + } + + if (commonButtonName == ButtonName.Engine) { + return param.get("engine", "") + } + + if (commonButtonName == ButtonName.Value) { + if (param.has("hat")) { + val hat = getDirectionName(param.get("direction", "")) + return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat) + } + if (param.has("axis")) { + val axis = param.get("axis", "") + return context.getString( + R.string.qualified_button_stick_axis, + toggle, + inverted, + invert, + axis + ) + } + if (param.has("button")) { + val button = param.get("button", "") + return context.getString(R.string.qualified_button, turbo, toggle, inverted, button) + } + } + + return context.getString(R.string.unknown) + } + + protected fun analogToText(param: ParamPackage, direction: String): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + if (param.get("engine", "") == "analog_from_button") { + return buttonToText(ParamPackage(param.get(direction, ""))) + } + + if (!param.has("axis_x") || !param.has("axis_y")) { + return context.getString(R.string.unknown) + } + + val xAxis = param.get("axis_x", "") + val yAxis = param.get("axis_y", "") + val xInvert = param.get("invert_x", "+") == "-" + val yInvert = param.get("invert_y", "+") == "-" + + if (direction == "modifier") { + return context.getString(R.string.unused) + } + + when (direction) { + "up" -> { + val yInvertString = if (yInvert) "+" else "-" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "down" -> { + val yInvertString = if (yInvert) "-" else "+" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "left" -> { + val xInvertString = if (xInvert) "+" else "-" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + + "right" -> { + val xInvertString = if (xInvert) "-" else "+" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + } + + return context.getString(R.string.unknown) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt new file mode 100644 index 0000000..3b77c6a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/ModifierInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.InputType +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.utils.ParamPackage + +class ModifierInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + val modifierParam = ParamPackage(analogParam.get("modifier", "")) + return buttonToText(modifierParam) + } + + override fun setSelectedValue(param: ParamPackage) { + val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + newParam.set("modifier", param.serialize()) + NativeInput.setStickParam(playerIndex, nativeAnalog, newParam) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100644 index 0000000..946f683 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting + +class SingleChoiceSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + @ArrayRes val choicesId: Int, + @ArrayRes val valuesId: Int +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SINGLE_CHOICE + + fun getSelectedValue(needsGlobal: Boolean = false) = + when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) + else -> -1 + } + + fun setSelectedValue(value: Int) = (setting as AbstractIntSetting).setInt(value) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt new file mode 100644 index 0000000..f488290 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/StringInputSetting.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractStringSetting + +class StringInputSetting( + setting: AbstractStringSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_STRING_INPUT + + fun getSelectedValue(needsGlobal: Boolean = false) = setting.getValueAsString(needsGlobal) + + fun setSelectedValue(selection: String) = + (setting as AbstractStringSetting).setString(selection) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt new file mode 100644 index 0000000..897935e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/model/view/SwitchSetting.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractSetting + +class SwitchSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SWITCH + + fun getIsChecked(needsGlobal: Boolean = false): Boolean { + return when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) == 1 + is AbstractBooleanSetting -> setting.getBoolean(needsGlobal) + else -> false + } + } + + fun setChecked(value: Boolean) { + when (setting) { + is AbstractIntSetting -> setting.setInt(if (value) 1 else 0) + is AbstractBooleanSetting -> setting.setBoolean(value) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt new file mode 100644 index 0000000..91fc566 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/InputDialogFragment.kt @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.app.Dialog +import android.graphics.drawable.Animatable2 +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogMappingBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.settings.model.view.AnalogInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ButtonInputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.InputSetting +import org.sudachi.sudachi_emu.features.settings.model.view.ModifierInputSetting +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.ParamPackage + +class InputDialogFragment : DialogFragment() { + private var inputAccepted = false + + private var position: Int = 0 + + private lateinit var inputSetting: InputSetting + + private lateinit var binding: DialogMappingBinding + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (settingsViewModel.clickedItem == null) dismiss() + + position = requireArguments().getInt(POSITION) + + InputHandler.updateControllerData() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + inputSetting = settingsViewModel.clickedItem as InputSetting + binding = DialogMappingBinding.inflate(layoutInflater) + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(android.R.string.cancel) { _, _ -> + NativeInput.stopMapping() + dismiss() + } + .setView(binding.root) + + val playButtonMapAnimation = { twoDirections: Boolean -> + val stickAnimation: AnimatedVectorDrawable + val buttonAnimation: AnimatedVectorDrawable + binding.imageStickAnimation.apply { + val anim = if (twoDirections) { + R.drawable.stick_two_direction_anim + } else { + R.drawable.stick_one_direction_anim + } + setBackgroundResource(anim) + stickAnimation = background as AnimatedVectorDrawable + } + binding.imageButtonAnimation.apply { + setBackgroundResource(R.drawable.button_anim) + buttonAnimation = background as AnimatedVectorDrawable + } + stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + buttonAnimation.start() + } + }) + buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + stickAnimation.start() + } + }) + stickAnimation.start() + } + + when (val setting = inputSetting) { + is AnalogInputSetting -> { + when (setting.nativeAnalog) { + NativeAnalog.LStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.left_stick)) + ) + + NativeAnalog.RStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.right_stick)) + ) + } + + builder.setMessage(R.string.stick_map_description) + + playButtonMapAnimation.invoke(true) + } + + is ModifierInputSetting -> { + builder.setTitle(getString(R.string.map_control, setting.title)) + .setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + + is ButtonInputSetting -> { + if (setting.nativeButton == NativeButton.DUp || + setting.nativeButton == NativeButton.DDown || + setting.nativeButton == NativeButton.DLeft || + setting.nativeButton == NativeButton.DRight + ) { + builder.setTitle(getString(R.string.map_dpad_direction, setting.title)) + } else { + builder.setTitle(getString(R.string.map_control, setting.title)) + } + builder.setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + } + + return builder.create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.requestFocus() + view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) } + binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) } + NativeInput.beginMapping(inputSetting.inputType.int) + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + onInputReceived(event.device) + return true + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + // Temp workaround for DPads that give both axis and button input. The input system can't + // take in a specific axis direction for a binding so you lose half of the directions for a DPad. + + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + onInputReceived(event.device) + } + return true + } + + private fun onInputReceived(device: InputDevice) { + val params = ParamPackage(NativeInput.getNextInput()) + if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) { + inputAccepted = true + setResult(params, device) + } + } + + private fun setResult(params: ParamPackage, device: InputDevice) { + NativeInput.stopMapping() + params.set("display", "${device.name} ${params.get("port", 0)}") + when (val item = settingsViewModel.clickedItem as InputSetting) { + is ModifierInputSetting, + is ButtonInputSetting -> { + // Invert DPad up and left bindings by default + val tempSetting = inputSetting as? ButtonInputSetting + if (tempSetting != null) { + if (tempSetting.nativeButton == NativeButton.DUp || + tempSetting.nativeButton == NativeButton.DLeft && + params.has("axis") + ) { + params.set("invert", "-") + } + } + + item.setSelectedValue(params) + settingsViewModel.setAdapterItemChanged(position) + } + + is AnalogInputSetting -> { + var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param) + + // Invert Y-Axis by default + analogParam.set("invert_y", "-") + + item.setSelectedValue(analogParam) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + } + dismiss() + } + + private fun adjustAnalogParam( + inputParam: ParamPackage, + analogParam: ParamPackage, + buttonName: String + ): ParamPackage { + // The poller returned a complete axis, so set all the buttons + if (inputParam.has("axis_x") && inputParam.has("axis_y")) { + return inputParam + } + + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) { + analogParam.clear() + analogParam.set("engine", "analog_from_button") + } + analogParam.set(buttonName, inputParam.serialize()) + return analogParam + } + + private fun isInputAcceptable(params: ParamPackage): Boolean { + if (InputHandler.registeredControllers.size == 1) { + return true + } + + if (params.has("motion")) { + return true + } + + val currentDevice = settingsViewModel.getCurrentDeviceParams(params) + if (currentDevice.get("engine", "any") == "any") { + return true + } + + val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") || + params.get("guid", "") == currentDevice.get("guid2", "") + return params.get("engine", "") == currentDevice.get("engine", "") && + guidMatch && + params.get("port", 0) == currentDevice.get("port", 0) + } + + companion object { + const val TAG = "InputDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + inputMappingViewModel: SettingsViewModel, + setting: InputSetting, + position: Int + ): InputDialogFragment { + inputMappingViewModel.clickedItem = setting + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt new file mode 100644 index 0000000..84f6bd0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsActivity.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.navArgs +import com.google.android.material.color.MaterialColors +import org.sudachi.sudachi_emu.NativeLibrary +import java.io.IOException +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.ActivitySettingsBinding +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.fragments.ResetSettingsDialogFragment +import org.sudachi.sudachi_emu.utils.* + +class SettingsActivity : AppCompatActivity() { + private lateinit var binding: ActivitySettingsBinding + + private val args by navArgs() + + private val settingsViewModel: SettingsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) { + SettingsFile.loadCustomConfig(args.game!!) + } + settingsViewModel.game = args.game + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.settings_navigation, intent.extras) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.navigationBarShade, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + settingsViewModel.shouldRecreate.collect( + this, + resetState = { settingsViewModel.setShouldRecreate(false) } + ) { if (it) recreate() } + settingsViewModel.shouldNavigateBack.collect( + this, + resetState = { settingsViewModel.setShouldNavigateBack(false) } + ) { if (it) navigateBack() } + settingsViewModel.shouldShowResetSettingsDialog.collect( + this, + resetState = { settingsViewModel.setShouldShowResetSettingsDialog(false) } + ) { + if (it) { + ResetSettingsDialogFragment().show( + supportFragmentManager, + ResetSettingsDialogFragment.TAG + ) + } + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + } + ) + + setInsets() + } + + fun navigateBack() { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + if (navHostFragment.childFragmentManager.backStackEntryCount > 0) { + navHostFragment.navController.popBackStack() + } else { + finish() + } + } + + override fun onStart() { + super.onStart() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + } + + override fun onStop() { + super.onStop() + Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...") + if (isFinishing) { + NativeInput.reloadInputDevices() + NativeLibrary.applySettings() + if (args.game == null) { + NativeConfig.saveGlobalConfig() + } else if (NativeConfig.isPerGameConfigLoaded()) { + NativeLibrary.logSettings() + NativeConfig.savePerGameConfig() + NativeConfig.unloadPerGameConfig() + } + } + } + + fun onSettingsReset() { + // Delete settings file because the user may have changed values that do not exist in the UI + if (args.game == null) { + NativeConfig.unloadGlobalConfig() + val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + NativeConfig.initializeGlobalConfig() + } else { + NativeConfig.unloadPerGameConfig() + val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + } + + Toast.makeText( + applicationContext, + getString(R.string.settings_reset), + Toast.LENGTH_LONG + ).show() + finish() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.navigationBarShade + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100644 index 0000000..725fe74 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -0,0 +1,975 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import android.annotation.SuppressLint +import android.os.Build +import android.widget.Toast +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.model.AnalogDirection +import org.sudachi.sudachi_emu.features.input.model.NativeAnalog +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.features.input.model.NpadStyleIndex +import org.sudachi.sudachi_emu.features.settings.model.AbstractBooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.AbstractIntSetting +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.ByteSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.LongSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.features.settings.model.Settings.MenuTag +import org.sudachi.sudachi_emu.features.settings.model.ShortSetting +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import org.sudachi.sudachi_emu.features.settings.model.view.* +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.NativeConfig + +class SettingsFragmentPresenter( + private val settingsViewModel: SettingsViewModel, + private val adapter: SettingsAdapter, + private var menuTag: MenuTag +) { + private var settingsList = ArrayList() + + private val context get() = SudachiApplication.appContext + + // Extension for altering settings list based on each setting's properties + fun ArrayList.add(key: String) { + val item = SettingsItem.settingsItems[key]!! + if (settingsViewModel.game != null && !item.setting.isSwitchable) { + return + } + + if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) { + item.setting.global = true + } + + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean( + pairedSettingKey, + if (NativeLibrary.isRunning() && !NativeConfig.isPerGameConfigLoaded()) { + !NativeConfig.usingGlobal(pairedSettingKey) + } else { + NativeConfig.usingGlobal(pairedSettingKey) + } + ) + if (!pairedSettingValue) return + } + add(item) + } + + // Allows you to show/hide abstract settings based on the paired setting key + fun ArrayList.addAbstract(item: SettingsItem) { + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingsItem = + this.firstOrNull { it.setting.key == pairedSettingKey } ?: return + val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting + if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return + } + add(item) + } + + fun onViewCreated() { + loadSettingsList() + } + + @SuppressLint("NotifyDataSetChanged") + fun loadSettingsList(notifyDataSetChanged: Boolean = false) { + val sl = ArrayList() + when (menuTag) { + MenuTag.SECTION_ROOT -> addConfigSettings(sl) + MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) + MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) + MenuTag.SECTION_AUDIO -> addAudioSettings(sl) + MenuTag.SECTION_INPUT -> addInputSettings(sl) + MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0) + MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1) + MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2) + MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3) + MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4) + MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5) + MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6) + MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) + MenuTag.SECTION_THEME -> addThemeSettings(sl) + MenuTag.SECTION_DEBUG -> addDebugSettings(sl) + } + settingsList = sl + adapter.submitList(settingsList) { + if (notifyDataSetChanged) { + adapter.notifyDataSetChanged() + } + } + } + + private fun addConfigSettings(sl: ArrayList) { + sl.apply { + add( + SubmenuSetting( + titleId = R.string.preferences_system, + descriptionId = R.string.preferences_system_description, + iconId = R.drawable.ic_system_settings, + menuKey = MenuTag.SECTION_SYSTEM + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_graphics, + descriptionId = R.string.preferences_graphics_description, + iconId = R.drawable.ic_graphics, + menuKey = MenuTag.SECTION_RENDERER + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_audio, + descriptionId = R.string.preferences_audio_description, + iconId = R.drawable.ic_audio, + menuKey = MenuTag.SECTION_AUDIO + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_debug, + descriptionId = R.string.preferences_debug_description, + iconId = R.drawable.ic_code, + menuKey = MenuTag.SECTION_DEBUG + ) + ) + add( + RunnableSetting( + titleId = R.string.reset_to_default, + descriptionId = R.string.reset_to_default_description, + isRunnable = !NativeLibrary.isRunning(), + iconId = R.drawable.ic_restore + ) { settingsViewModel.setShouldShowResetSettingsDialog(true) } + ) + } + } + + private fun addSystemSettings(sl: ArrayList) { + sl.apply { + add(StringSetting.DEVICE_NAME.key) + add(BooleanSetting.RENDERER_USE_SPEED_LIMIT.key) + add(ShortSetting.RENDERER_SPEED_LIMIT.key) + add(BooleanSetting.USE_DOCKED_MODE.key) + add(IntSetting.REGION_INDEX.key) + add(IntSetting.LANGUAGE_INDEX.key) + add(BooleanSetting.USE_CUSTOM_RTC.key) + add(LongSetting.CUSTOM_RTC.key) + } + } + + private fun addGraphicsSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.RENDERER_ACCURACY.key) + add(IntSetting.RENDERER_RESOLUTION.key) + add(IntSetting.RENDERER_VSYNC.key) + add(IntSetting.RENDERER_SCALING_FILTER.key) + add(IntSetting.FSR_SHARPENING_SLIDER.key) + add(IntSetting.RENDERER_ANTI_ALIASING.key) + add(IntSetting.MAX_ANISOTROPY.key) + add(IntSetting.RENDERER_SCREEN_LAYOUT.key) + add(IntSetting.RENDERER_ASPECT_RATIO.key) + add(IntSetting.VERTICAL_ALIGNMENT.key) + add(BooleanSetting.PICTURE_IN_PICTURE.key) + add(BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE.key) + add(BooleanSetting.RENDERER_FORCE_MAX_CLOCK.key) + add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) + add(BooleanSetting.RENDERER_REACTIVE_FLUSHING.key) + } + } + + private fun addAudioSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.AUDIO_OUTPUT_ENGINE.key) + add(ByteSetting.AUDIO_VOLUME.key) + } + } + + private fun addInputSettings(sl: ArrayList) { + settingsViewModel.currentDevice = 0 + + if (NativeConfig.isPerGameConfigLoaded()) { + NativeInput.loadInputProfiles() + val profiles = NativeInput.getInputProfileNames().toMutableList() + profiles.add(0, "") + val prettyProfiles = profiles.toTypedArray() + prettyProfiles[0] = + context.getString(R.string.use_global_input_configuration) + sl.apply { + for (i in 0 until 8) { + add( + IntSingleChoiceSetting( + getPerGameProfileSetting(profiles, i), + titleString = getPlayerProfileString(i + 1), + choices = prettyProfiles, + values = IntArray(profiles.size) { it }.toTypedArray() + ) + ) + } + } + return + } + + val getConnectedIcon: (Int) -> Int = { playerIndex: Int -> + if (NativeInput.getIsConnected(playerIndex)) { + R.drawable.ic_controller + } else { + R.drawable.ic_controller_disconnected + } + } + + val inputSettings = NativeConfig.getInputSettings(true) + sl.apply { + add( + SubmenuSetting( + titleString = Settings.getPlayerString(1), + descriptionString = inputSettings[0].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE, + iconId = getConnectedIcon(0) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(2), + descriptionString = inputSettings[1].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO, + iconId = getConnectedIcon(1) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(3), + descriptionString = inputSettings[2].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE, + iconId = getConnectedIcon(2) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(4), + descriptionString = inputSettings[3].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR, + iconId = getConnectedIcon(3) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(5), + descriptionString = inputSettings[4].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE, + iconId = getConnectedIcon(4) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(6), + descriptionString = inputSettings[5].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX, + iconId = getConnectedIcon(5) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(7), + descriptionString = inputSettings[6].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN, + iconId = getConnectedIcon(6) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(8), + descriptionString = inputSettings[7].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT, + iconId = getConnectedIcon(7) + ) + ) + } + } + + private fun getPlayerProfileString(player: Int): String = + context.getString(R.string.player_num_profile, player) + + private fun getPerGameProfileSetting( + profiles: List, + playerIndex: Int + ): AbstractIntSetting { + return object : AbstractIntSetting { + private val players + get() = NativeConfig.getInputSettings(false) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int { + val currentProfile = players[playerIndex].profileName + profiles.forEachIndexed { i, profile -> + if (profile == currentProfile) { + return i + } + } + return 0 + } + + override fun setInt(value: Int) { + NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value]) + NativeInput.connectControllers(playerIndex) + NativeConfig.saveControlPlayerValues() + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override var global = true + + override val isRuntimeModifiable = true + + override val isSaveable = true + } + } + + private fun addInputPlayer(sl: ArrayList, playerIndex: Int) { + sl.apply { + val connectedSetting = object : AbstractBooleanSetting { + override val key = "connected" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeInput.getIsConnected(playerIndex) + + override fun setBoolean(value: Boolean) = + NativeInput.connectControllers(playerIndex, value) + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(connectedSetting, R.string.connected)) + + val styleTags = NativeInput.getSupportedStyleTags(playerIndex) + val npadType = object : AbstractIntSetting { + override val key = "npad_type" + override fun getInt(needsGlobal: Boolean): Int { + val styleIndex = NativeInput.getStyleIndex(playerIndex) + return styleTags.indexOfFirst { it == styleIndex } + } + + override fun setInt(value: Int) { + NativeInput.setStyleIndex(playerIndex, styleTags[value]) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = NpadStyleIndex.Fullkey.int + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + override fun reset() = setInt(defaultValue) + override val pairedSettingKey: String = "connected" + } + addAbstract( + IntSingleChoiceSetting( + npadType, + titleId = R.string.controller_type, + choices = styleTags.map { context.getString(it.nameId) } + .toTypedArray(), + values = IntArray(styleTags.size) { it }.toTypedArray() + ) + ) + + InputHandler.updateControllerData() + + val autoMappingSetting = object : AbstractIntSetting { + override val key = "auto_mapping_device" + + override fun getInt(needsGlobal: Boolean): Int = -1 + + override fun setInt(value: Int) { + val registeredController = InputHandler.registeredControllers[value + 1] + val displayName = registeredController.get( + "display", + context.getString(R.string.unknown) + ) + NativeInput.updateMappingsWithDefault( + playerIndex, + registeredController, + displayName + ) + Toast.makeText( + context, + context.getString(R.string.attempted_auto_map, displayName), + Toast.LENGTH_SHORT + ).show() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = -1 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val unknownString = context.getString(R.string.unknown) + val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull { + val port = it.get("port", -1) + return@mapNotNull if (port == 100 || port == -1) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + autoMappingSetting, + titleId = R.string.auto_map, + descriptionId = R.string.auto_map_description, + choices = prettyAutoMappingControllerList, + values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray() + ) + ) + + val mappingFilterSetting = object : AbstractIntSetting { + override val key = "mapping_filter" + + override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice + + override fun setInt(value: Int) { + settingsViewModel.currentDevice = value + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val prettyControllerList = InputHandler.registeredControllers.mapNotNull { + return@mapNotNull if (it.get("port", 0) == 100) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + mappingFilterSetting, + titleId = R.string.input_mapping_filter, + descriptionId = R.string.input_mapping_filter_description, + choices = prettyControllerList, + values = IntArray(prettyControllerList.size) { it }.toTypedArray() + ) + ) + + add(InputProfileSetting(playerIndex)) + add( + RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) { + settingsViewModel.setShouldShowResetInputDialog(true) + } + ) + + val styleIndex = NativeInput.getStyleIndex(playerIndex) + + // Buttons + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause)) + } + + else -> { + // No-op + } + } + + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.dpad)) + add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up)) + add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down)) + add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left)) + add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right)) + } + + else -> { + // No-op + } + } + + // Left stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.left_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.control_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + else -> { + // No-op + } + } + + // Right stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.right_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.c_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + else -> { + // No-op + } + } + + // L/R, ZL/ZR, and SL/SR + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + } + + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r)) + } + + else -> { + // No-op + } + } + + add(HeaderSetting(R.string.vibration)) + val vibrationEnabledSetting = object : AbstractBooleanSetting { + override val key = "vibration" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationEnabled = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(vibrationEnabledSetting, R.string.vibration)) + + val useSystemVibratorSetting = object : AbstractBooleanSetting { + override val key = "" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].useSystemVibrator = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator)) + + val vibrationStrengthSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength + + override fun setInt(value: Int) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationStrength = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = 100 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract( + SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%") + ) + } + } + + // Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones + private fun getStickIntSettingFromParam( + playerIndex: Int, + paramName: String, + stick: NativeAnalog, + defaultValue: Float + ): AbstractIntSetting = + object : AbstractIntSetting { + val params get() = NativeInput.getStickParam(playerIndex, stick) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get(paramName, defaultValue) * 100).toInt() + + override fun setInt(value: Int) { + val tempParams = params + tempParams.set(paramName, value.toFloat() / 100) + NativeInput.setStickParam(playerIndex, stick, tempParams) + } + + override val defaultValue = (defaultValue * 100).toInt() + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(this.defaultValue) + } + + private fun getExtraStickSettings( + playerIndex: Int, + nativeAnalog: NativeAnalog + ): List { + val stickIsController = + NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog)) + val modifierRangeSetting = + getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 0.5f) + val stickRangeSetting = + getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 0.95f) + val stickDeadzoneSetting = + getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 0.15f) + + val out = mutableListOf().apply { + if (stickIsController) { + add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150)) + add(SliderSetting(stickDeadzoneSetting, R.string.deadzone)) + } else { + add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier)) + add(SliderSetting(modifierRangeSetting, R.string.modifier_range)) + } + } + return out + } + + private fun getStickDirections(player: Int, stick: NativeAnalog): List = + listOf( + AnalogInputSetting( + player, + stick, + AnalogDirection.Up, + R.string.up + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Down, + R.string.down + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Left, + R.string.left + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Right, + R.string.right + ) + ) + + private fun addThemeSettings(sl: ArrayList) { + sl.apply { + val theme: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME.getInt() + override fun setInt(value: Int) { + IntSetting.THEME.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME.key + override val isRuntimeModifiable: Boolean = IntSetting.THEME.isRuntimeModifiable + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME.defaultValue + override fun reset() = IntSetting.THEME.setInt(defaultValue) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntriesA12, + valuesId = R.array.themeValuesA12 + ) + ) + } else { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntries, + valuesId = R.array.themeValues + ) + ) + } + + val themeMode: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME_MODE.getInt() + override fun setInt(value: Int) { + IntSetting.THEME_MODE.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME_MODE.key + override val isRuntimeModifiable: Boolean = + IntSetting.THEME_MODE.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME_MODE.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME_MODE.defaultValue + override fun reset() { + IntSetting.THEME_MODE.setInt(defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SingleChoiceSetting( + themeMode, + titleId = R.string.change_theme_mode, + choicesId = R.array.themeModeEntries, + valuesId = R.array.themeModeValues + ) + ) + + val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { + override fun getBoolean(needsGlobal: Boolean): Boolean = + BooleanSetting.BLACK_BACKGROUNDS.getBoolean() + + override fun setBoolean(value: Boolean) { + BooleanSetting.BLACK_BACKGROUNDS.setBoolean(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = BooleanSetting.BLACK_BACKGROUNDS.key + override val isRuntimeModifiable: Boolean = + BooleanSetting.BLACK_BACKGROUNDS.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + BooleanSetting.BLACK_BACKGROUNDS.getValueAsString() + + override val defaultValue: Boolean = BooleanSetting.BLACK_BACKGROUNDS.defaultValue + override fun reset() { + BooleanSetting.BLACK_BACKGROUNDS + .setBoolean(BooleanSetting.BLACK_BACKGROUNDS.defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SwitchSetting( + blackBackgrounds, + titleId = R.string.use_black_backgrounds, + descriptionId = R.string.use_black_backgrounds_description + ) + ) + } + } + + private fun addDebugSettings(sl: ArrayList) { + sl.apply { + add(HeaderSetting(R.string.gpu)) + add(IntSetting.RENDERER_BACKEND.key) + add(BooleanSetting.RENDERER_DEBUG.key) + + add(HeaderSetting(R.string.cpu)) + add(IntSetting.CPU_BACKEND.key) + add(IntSetting.CPU_ACCURACY.key) + add(BooleanSetting.CPU_DEBUG_MODE.key) + add(SettingsItem.FASTMEM_COMBINED) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt new file mode 100644 index 0000000..9f03729 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/SettingsViewModel.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.utils.InputHandler +import org.sudachi.sudachi_emu.utils.ParamPackage + +class SettingsViewModel : ViewModel() { + var game: Game? = null + + var clickedItem: SettingsItem? = null + + var currentDevice = 0 + + val shouldRecreate: StateFlow get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow get() = _adapterItemChanged + private val _adapterItemChanged = MutableStateFlow(-1) + + private val _datasetChanged = MutableStateFlow(false) + val datasetChanged = _datasetChanged.asStateFlow() + + private val _reloadListAndNotifyDataset = MutableStateFlow(false) + val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow() + + private val _shouldShowDeleteProfileDialog = MutableStateFlow("") + val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow() + + private val _shouldShowResetInputDialog = MutableStateFlow(false) + val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow() + + fun setShouldRecreate(value: Boolean) { + _shouldRecreate.value = value + } + + fun setShouldNavigateBack(value: Boolean) { + _shouldNavigateBack.value = value + } + + fun setShouldShowResetSettingsDialog(value: Boolean) { + _shouldShowResetSettingsDialog.value = value + } + + fun setShouldReloadSettingsList(value: Boolean) { + _shouldReloadSettingsList.value = value + } + + fun setSliderTextValue(value: Float, units: String) { + _sliderProgress.value = value.toInt() + _sliderTextValue.value = String.format( + SudachiApplication.appContext.getString(R.string.value_with_units), + value.toInt().toString(), + units + ) + } + + fun setSliderProgress(value: Float) { + _sliderProgress.value = value.toInt() + } + + fun setAdapterItemChanged(value: Int) { + _adapterItemChanged.value = value + } + + fun setDatasetChanged(value: Boolean) { + _datasetChanged.value = value + } + + fun setReloadListAndNotifyDataset(value: Boolean) { + _reloadListAndNotifyDataset.value = value + } + + fun setShouldShowDeleteProfileDialog(profile: String) { + _shouldShowDeleteProfileDialog.value = profile + } + + fun setShouldShowResetInputDialog(value: Boolean) { + _shouldShowResetInputDialog.value = value + } + + fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage = + try { + InputHandler.registeredControllers[currentDevice] + } catch (e: IndexOutOfBoundsException) { + defaultParams + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100644 index 0000000..6305e70 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.DateTimeSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: DateTimeSetting + + override fun bind(item: SettingsItem) { + setting = item as DateTimeSetting + binding.textSettingName.text = item.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + binding.textSettingValue.setVisible(true) + val epochTime = setting.getValue() + val instant = Instant.ofEpochMilli(epochTime * 1000) + val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + binding.textSettingValue.text = dateFormatter.format(zonedTime) + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onDateTimeClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100644 index 0000000..b8a4629 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingsHeaderBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter + +class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + init { + itemView.setOnClickListener(null) + } + + override fun bind(item: SettingsItem) { + binding.textHeaderName.text = item.title + } + + override fun onClick(clicked: View) { + // no-op + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt new file mode 100644 index 0000000..693b8d5 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.InputProfileSetting +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputProfileSetting + + override fun bind(item: SettingsItem) { + setting = item as InputProfileSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = + setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) } + + binding.textSettingDescription.setVisible(false) + binding.buttonClear.setVisible(false) + binding.icon.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) = + adapter.onInputProfileClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = false +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100644 index 0000000..4e60b9e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.core.content.res.ResourcesCompat +import org.sudachi.sudachi_emu.databinding.ListItemSettingBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SubmenuSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SubmenuSetting + + override fun bind(item: SettingsItem) { + setting = item as SubmenuSetting + binding.icon.setVisible(setting.iconId != 0) + if (setting.iconId != 0) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.resources, + setting.iconId, + binding.icon.context.theme + ) + ) + } + + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) { + adapter.onSubmenuClick(setting) + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100644 index 0000000..f2002ce --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.ui.viewholder + +import android.view.View +import android.widget.CompoundButton +import org.sudachi.sudachi_emu.databinding.ListItemSettingSwitchBinding +import org.sudachi.sudachi_emu.features.settings.model.view.SettingsItem +import org.sudachi.sudachi_emu.features.settings.model.view.SwitchSetting +import org.sudachi.sudachi_emu.features.settings.ui.SettingsAdapter +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible + +class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + private lateinit var setting: SwitchSetting + + override fun bind(item: SettingsItem) { + setting = item as SwitchSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + + binding.switchWidget.setOnCheckedChangeListener(null) + binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal) + binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + adapter.onBooleanClick(setting, binding.switchWidget.isChecked, bindingAdapterPosition) + } + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + binding.switchWidget.toggle() + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt new file mode 100644 index 0000000..a9b9d84 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/features/settings/utils/SettingsFile.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.features.settings.utils + +import android.net.Uri +import org.sudachi.sudachi_emu.model.Game +import java.io.* +import org.sudachi.sudachi_emu.utils.DirectoryInitialization +import org.sudachi.sudachi_emu.utils.FileUtil +import org.sudachi.sudachi_emu.utils.NativeConfig + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +object SettingsFile { + const val FILE_NAME_CONFIG = "config.ini" + + fun getSettingsFile(fileName: String): File = + File(DirectoryInitialization.userDirectory + "/config/" + fileName) + + fun getCustomSettingsFile(game: Game): File = + File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini") + + fun loadCustomConfig(game: Game) { + val fileName = FileUtil.getFilename(Uri.parse(game.path)) + NativeConfig.initializePerGameConfig(game.programId, fileName) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt new file mode 100644 index 0000000..e39bdd5 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddGameFolderDialogFragment.kt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogAddFolderBinding +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel + +class AddGameFolderDialogFragment : DialogFragment() { + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogAddFolderBinding.inflate(layoutInflater) + val folderUriString = requireArguments().getString(FOLDER_URI_STRING) + if (folderUriString == null) { + dismiss() + } + binding.path.text = Uri.parse(folderUriString).path + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.add_game_folder) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) + homeViewModel.setGamesDirSelected(true) + gamesViewModel.addFolder(newGameDir) + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "AddGameFolderDialogFragment" + + private const val FOLDER_URI_STRING = "FolderUriString" + + fun newInstance(folderUriString: String): AddGameFolderDialogFragment { + val args = Bundle() + args.putString(FOLDER_URI_STRING, folderUriString) + val fragment = AddGameFolderDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt new file mode 100644 index 0000000..07d5882 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/AddonsFragment.kt @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.AddonAdapter +import org.sudachi.sudachi_emu.databinding.FragmentAddonsBinding +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.AddonUtil +import org.sudachi.sudachi_emu.utils.FileUtil.copyFilesTo +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins +import org.sudachi.sudachi_emu.utils.collect +import java.io.File + +class AddonsFragment : Fragment() { + private var _binding: FragmentAddonsBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + addonViewModel.onOpenAddons(args.game) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddonsBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.toolbarAddons.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) + + binding.listAddons.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = AddonAdapter(addonViewModel) + } + + addonViewModel.addonList.collect(viewLifecycleOwner) { + (binding.listAddons.adapter as AddonAdapter).submitList(it) + } + addonViewModel.showModInstallPicker.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModInstallPicker(false) } + ) { if (it) installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) } + addonViewModel.showModNoticeDialog.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModNoticeDialog(false) } + ) { + if (it) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.addon_notice, + descriptionId = R.string.addon_notice_description, + dismissible = false, + positiveAction = { addonViewModel.showModInstallPicker(true) }, + negativeAction = {}, + negativeButtonTitleId = R.string.close + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + addonViewModel.addonToDelete.collect( + viewLifecycleOwner, + resetState = { addonViewModel.setAddonToDelete(null) } + ) { + if (it != null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.confirm_uninstall, + descriptionId = R.string.confirm_uninstall_description, + positiveAction = { addonViewModel.onDeleteAddon(it) }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + binding.buttonInstall.setOnClickListener { + ContentTypeSelectionDialogFragment().show( + parentFragmentManager, + ContentTypeSelectionDialogFragment.TAG + ) + } + + setInsets() + } + + override fun onResume() { + super.onResume() + addonViewModel.refreshAddons() + } + + override fun onDestroy() { + super.onDestroy() + addonViewModel.onCloseAddons() + } + + val installAddon = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) + if (externalAddonDirectory == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + val isValid = externalAddonDirectory.listFiles() + .any { AddonUtil.validAddonDirectories.contains(it.name?.lowercase()) } + val errorMessage = MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ) + if (isValid) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_game_content, + false + ) { progressCallback, _ -> + val parentDirectoryName = externalAddonDirectory.name + val internalAddonDirectory = + File(args.game.addonDir + parentDirectoryName) + try { + externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback) + } catch (_: Exception) { + return@newInstance errorMessage + } + addonViewModel.refreshAddons() + return@newInstance getString(R.string.addon_installed_successfully) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } else { + errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonInstall.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + windowInsets + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt new file mode 100644 index 0000000..384daf0 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ContentTypeSelectionDialogFragment.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.ui.main.MainActivity + +class ContentTypeSelectionDialogFragment : DialogFragment() { + private val addonViewModel: AddonViewModel by activityViewModels() + + private val preferences get() = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + + private var selectedItem = 0 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val launchOptions = + arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + val mainActivity = requireActivity() as MainActivity + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_content_type) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (selectedItem) { + 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) + else -> { + if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { + preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() + addonViewModel.showModNoticeDialog(true) + return@setPositiveButton + } + addonViewModel.showModInstallPicker(true) + } + } + } + .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "ContentTypeSelectionDialogFragment" + + private const val SELECTED_ITEM = "SelectedItem" + private const val MOD_NOTICE_SEEN = "ModNoticeSeen" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt new file mode 100644 index 0000000..c750e9e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/CoreErrorDialogFragment.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R + +class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(requireArguments().getString(TITLE)) + .setMessage(requireArguments().getString(MESSAGE)) + .setPositiveButton(R.string.continue_button, null) + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + NativeLibrary.coreErrorAlertResult = false + synchronized(NativeLibrary.coreErrorAlertLock) { + NativeLibrary.coreErrorAlertLock.notify() + } + } + .create() + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + NativeLibrary.coreErrorAlertResult = true + synchronized(NativeLibrary.coreErrorAlertLock) { NativeLibrary.coreErrorAlertLock.notify() } + } + + companion object { + const val TITLE = "Title" + const val MESSAGE = "Message" + + fun newInstance(title: String, message: String): CoreErrorDialogFragment { + val frag = CoreErrorDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 0000000..bb34bd3 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/DriversLoadingDialogFragment.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogProgressBarBinding +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.utils.collect + +class DriversLoadingDialogFragment : DialogFragment() { + private val driverViewModel: DriverViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + + isCancelable = false + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.loading) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { if (it) dismiss() } + } + + companion object { + const val TAG = "DriversLoadingDialogFragment" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt new file mode 100644 index 0000000..0712521 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EarlyAccessFragment.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentEarlyAccessBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class EarlyAccessFragment : Fragment() { + private var _binding: FragmentEarlyAccessBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEarlyAccessBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarAbout.setNavigationOnClickListener { + parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack() + } + + binding.getEarlyAccessButton.setOnClickListener { + openLink( + getString(R.string.play_store_link) + ) + } + + setInsets() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarEa.updateMargins(left = leftInsets, right = rightInsets) + + binding.scrollEa.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + windowInsets + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt new file mode 100644 index 0000000..d5ca95d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/EmulationFragment.kt @@ -0,0 +1,1048 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.util.Rational +import android.view.* +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.activities.EmulationActivity +import org.sudachi.sudachi_emu.databinding.DialogOverlayAdjustBinding +import org.sudachi.sudachi_emu.databinding.FragmentEmulationBinding +import org.sudachi.sudachi_emu.features.settings.model.BooleanSetting +import org.sudachi.sudachi_emu.features.settings.model.IntSetting +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.features.settings.model.Settings.EmulationOrientation +import org.sudachi.sudachi_emu.features.settings.model.Settings.EmulationVerticalAlignment +import org.sudachi.sudachi_emu.features.settings.utils.SettingsFile +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.EmulationViewModel +import org.sudachi.sudachi_emu.overlay.model.OverlayControl +import org.sudachi.sudachi_emu.overlay.model.OverlayLayout +import org.sudachi.sudachi_emu.utils.* +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import java.lang.NullPointerException + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + private var thermalStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private lateinit var game: Game + + private val emulationViewModel: EmulationViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private var isInFoldableLayout = false + + private lateinit var powerManager: PowerManager + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is EmulationActivity) { + emulationActivity = context + NativeLibrary.setEmulationActivity(context) + } else { + throw IllegalStateException("EmulationFragment must have EmulationActivity parent") + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + updateOrientation() + + powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + + val intentUri: Uri? = requireActivity().intent.data + var intentGame: Game? = null + if (intentUri != null) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + GameHelper.getGame(requireActivity().intent.data!!, false) + } else { + null + } + } + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + + // Always load custom settings when launching a game from an intent + if (args.custom || intentGame != null) { + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + } else { + NativeConfig.reloadGlobalConfig() + } + + // Install the selected driver asynchronously as the game starts + driverViewModel.onLaunchGame() + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + emulationState = EmulationState(game.path) { + return@EmulationState driverViewModel.isInteractionAllowed.value + } + } + + /** + * Initialize the UI and start emulation in here. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmulationBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + + binding.surfaceEmulation.holder.addCallback(this) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + binding.surfaceInputOverlay.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis() + 100, + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) + ) + } + + override fun onDrawerOpened(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + binding.inGameMenu.requestFocus() + emulationViewModel.setDrawerOpen(true) + } + + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + emulationViewModel.setDrawerOpen(false) + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { + val lockMode = IntSetting.LOCK_DRAWER.getInt() + val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + R.string.unlock_drawer + } else { + R.string.lock_drawer + } + val iconId = if (lockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { + R.drawable.ic_unlock + } else { + R.drawable.ic_lock + } + + title = getString(titleId) + icon = ResourcesCompat.getDrawable( + resources, + iconId, + requireContext().theme + ) + } + + binding.inGameMenu.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.menu_pause_emulation -> { + if (emulationState.isPaused) { + emulationState.run(false) + it.title = resources.getString(R.string.emulation_pause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + requireContext().theme + ) + } else { + emulationState.pause() + it.title = resources.getString(R.string.emulation_unpause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + requireContext().theme + ) + } + binding.inGameMenu.requestFocus() + true + } + + R.id.menu_settings -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_settings_per_game -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_controls -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_lock_drawer -> { + when (IntSetting.LOCK_DRAWER.getInt()) { + DrawerLayout.LOCK_MODE_UNLOCKED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + it.title = resources.getString(R.string.unlock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_lock, + requireContext().theme + ) + } + + DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_UNLOCKED) + it.title = resources.getString(R.string.lock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_unlock, + requireContext().theme + ) + } + } + binding.inGameMenu.requestFocus() + NativeConfig.saveGlobalConfig() + true + } + + R.id.menu_exit -> { + emulationState.stop() + NativeConfig.reloadGlobalConfig() + emulationViewModel.setIsEmulationStopping(true) + binding.drawerLayout.close() + binding.inGameMenu.requestFocus() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!NativeLibrary.isRunning()) { + return + } + emulationViewModel.setDrawerOpen(!binding.drawerLayout.isOpen) + } + } + ) + + GameIconUtils.loadGameIcon(game, binding.loadingImage) + binding.loadingTitle.text = game.title + binding.loadingTitle.isSelected = true + binding.loadingText.isSelected = true + + WindowInfoTracker.getOrCreate(requireContext()) + .windowLayoutInfo(requireActivity()).collect(viewLifecycleOwner) { + updateFoldableLayout(requireActivity() as EmulationActivity, it) + } + emulationViewModel.shaderProgress.collect(viewLifecycleOwner) { + if (it > 0 && it != emulationViewModel.totalShaders.value) { + binding.loadingProgressIndicator.isIndeterminate = false + + if (it < binding.loadingProgressIndicator.max) { + binding.loadingProgressIndicator.progress = it + } + } + + if (it == emulationViewModel.totalShaders.value) { + binding.loadingText.setText(R.string.loading) + binding.loadingProgressIndicator.isIndeterminate = true + } + } + emulationViewModel.totalShaders.collect(viewLifecycleOwner) { + binding.loadingProgressIndicator.max = it + } + emulationViewModel.shaderMessage.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.loadingText.text = it + } + } + + emulationViewModel.emulationStarted.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + ViewUtils.showView(binding.surfaceInputOverlay) + ViewUtils.hideView(binding.loadingIndicator) + + emulationState.updateSurface() + + // Setup overlays + updateShowFpsOverlay() + updateThermalOverlay() + } + } + emulationViewModel.isEmulationStopping.collect(viewLifecycleOwner) { + if (it) { + binding.loadingText.setText(R.string.shutting_down) + ViewUtils.showView(binding.loadingIndicator) + ViewUtils.hideView(binding.inputContainer) + ViewUtils.hideView(binding.showFpsText) + } + } + emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.open() + binding.inGameMenu.requestFocus() + } else { + binding.drawerLayout.close() + } + } + emulationViewModel.programChanged.collect(viewLifecycleOwner) { + if (it != 0) { + emulationViewModel.setEmulationStarted(false) + binding.drawerLayout.close() + binding.drawerLayout + .setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + ViewUtils.hideView(binding.surfaceInputOverlay) + ViewUtils.showView(binding.loadingIndicator) + } + } + emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { + if (it && emulationViewModel.programChanged.value != -1) { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + emulationState.changeProgram(emulationViewModel.programChanged.value) + emulationViewModel.setProgramChanged(-1) + emulationViewModel.setEmulationStopped(false) + } + } + + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { + if (it) startEmulation() + } + } + + private fun startEmulation(programIndex: Int = 0) { + if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + + updateScreenLayout() + val showInputOverlay = BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + if (emulationActivity?.isInPictureInPictureMode == true) { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } + if (showInputOverlay) { + binding.surfaceInputOverlay.setVisible(visible = false, gone = false) + } + } else { + binding.surfaceInputOverlay.setVisible( + showInputOverlay && emulationViewModel.emulationStarted.value + ) + if (!isInFoldableLayout) { + if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + binding.surfaceInputOverlay.layout = OverlayLayout.Portrait + } else { + binding.surfaceInputOverlay.layout = OverlayLayout.Landscape + } + } + } + } + + override fun onPause() { + if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun resetInputOverlay() { + IntSetting.OVERLAY_SCALE.reset() + IntSetting.OVERLAY_OPACITY.reset() + binding.surfaceInputOverlay.post { + binding.surfaceInputOverlay.resetLayoutVisibilityAndPlacement() + } + } + + private fun updateShowFpsOverlay() { + val showOverlay = BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + binding.showFpsText.setVisible(showOverlay) + if (showOverlay) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val perfStats = NativeLibrary.getPerfStats() + val cpuBackend = NativeLibrary.getCpuBackend() + val gpuDriver = NativeLibrary.getGpuDriver() + if (_binding != null) { + binding.showFpsText.text = + String.format("FPS: %.1f\n%s/%s", perfStats[FPS], cpuBackend, gpuDriver) + } + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + } + } + + private fun updateThermalOverlay() { + val showOverlay = BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + binding.showThermalsText.setVisible(showOverlay) + if (showOverlay) { + thermalStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val thermalStatus = when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_LIGHT -> "😥" + PowerManager.THERMAL_STATUS_MODERATE -> "🥵" + PowerManager.THERMAL_STATUS_SEVERE -> "🔥" + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> "☢️" + + else -> "🙂" + } + if (_binding != null) { + binding.showThermalsText.text = thermalStatus + } + thermalStatsUpdateHandler.postDelayed(thermalStatsUpdater!!, 1000) + } + } + thermalStatsUpdateHandler.post(thermalStatsUpdater!!) + } else { + if (thermalStatsUpdater != null) { + thermalStatsUpdateHandler.removeCallbacks(thermalStatsUpdater!!) + } + } + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun updateOrientation() { + emulationActivity?.let { + val orientationSetting = + EmulationOrientation.from(IntSetting.RENDERER_SCREEN_LAYOUT.getInt()) + it.requestedOrientation = when (orientationSetting) { + EmulationOrientation.Unspecified -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + EmulationOrientation.SensorLandscape -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + EmulationOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + EmulationOrientation.ReverseLandscape -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + EmulationOrientation.SensorPortrait -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + EmulationOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + EmulationOrientation.ReversePortrait -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + } + } + + private fun updateScreenLayout() { + val verticalAlignment = + EmulationVerticalAlignment.from(IntSetting.VERTICAL_ALIGNMENT.getInt()) + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + when (verticalAlignment) { + EmulationVerticalAlignment.Top -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + + EmulationVerticalAlignment.Center -> { + binding.surfaceEmulation.setAspectRatio(null) + binding.surfaceEmulation.updateLayoutParams { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + + EmulationVerticalAlignment.Bottom -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + } + emulationState.updateSurface() + emulationActivity?.buildPictureInPictureParams() + updateOrientation() + } + + private fun updateFoldableLayout( + emulationActivity: EmulationActivity, + newLayoutInfo: WindowLayoutInfo + ) { + val isFolding = + (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { + if (it.isSeparating) { + emulationActivity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { + // Restrict emulation and overlays to the top of the screen + binding.emulationContainer.layoutParams.height = it.bounds.top + // Restrict input and menu drawer to the bottom of the screen + binding.inputContainer.layoutParams.height = it.bounds.bottom + binding.inGameMenu.layoutParams.height = it.bounds.bottom + + isInFoldableLayout = true + binding.surfaceInputOverlay.layout = OverlayLayout.Foldable + } + } + it.isSeparating + } ?: false + if (!isFolding) { + binding.emulationContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inputContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + isInFoldableLayout = false + updateOrientation() + onConfigurationChanged(resources.configuration) + } + binding.emulationContainer.requestLayout() + binding.inputContainer.requestLayout() + binding.inGameMenu.requestLayout() + } + + override fun surfaceCreated(holder: SurfaceHolder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) + emulationState.newSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + emulationState.clearSurface() + } + + private fun showOverlayOptions() { + val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) + val popup = PopupMenu(requireContext(), anchor) + + popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) + + popup.menu.apply { + findItem(R.id.menu_toggle_fps).isChecked = + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + findItem(R.id.thermal_indicator).isChecked = + BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + findItem(R.id.menu_rel_stick_center).isChecked = + BooleanSetting.JOYSTICK_REL_CENTER.getBoolean() + findItem(R.id.menu_dpad_slide).isChecked = BooleanSetting.DPAD_SLIDE.getBoolean() + findItem(R.id.menu_show_overlay).isChecked = + BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + findItem(R.id.menu_haptics).isChecked = BooleanSetting.HAPTIC_FEEDBACK.getBoolean() + findItem(R.id.menu_touchscreen).isChecked = BooleanSetting.TOUCHSCREEN.getBoolean() + } + + popup.setOnDismissListener { NativeConfig.saveGlobalConfig() } + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(it.isChecked) + updateShowFpsOverlay() + true + } + + R.id.thermal_indicator -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_THERMAL_OVERLAY.setBoolean(it.isChecked) + updateThermalOverlay() + true + } + + R.id.menu_edit_overlay -> { + binding.drawerLayout.close() + binding.surfaceInputOverlay.requestFocus() + startConfiguringControls() + true + } + + R.id.menu_adjust_overlay -> { + adjustOverlay() + true + } + + R.id.menu_toggle_controls -> { + val overlayControlData = NativeConfig.getOverlayControlData() + val optionsArray = BooleanArray(overlayControlData.size) + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = overlayControlData.firstOrNull { data -> + OverlayControl.entries[i].id == data.id + }?.enabled == true + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + overlayControlData.firstOrNull { data -> + OverlayControl.entries[indexSelected].id == data.id + }?.enabled = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + NativeConfig.setOverlayControlData(overlayControlData) + NativeConfig.saveGlobalConfig() + binding.surfaceInputOverlay.refreshControls() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } + .show() + + // Override normal behaviour so the dialog doesn't close + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener { + val isChecked = !optionsArray[0] + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = isChecked + dialog.listView.setItemChecked(i, isChecked) + overlayControlData[i].enabled = isChecked + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(it.isChecked) + binding.surfaceInputOverlay.refreshControls() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(it.isChecked) + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + BooleanSetting.DPAD_SLIDE.setBoolean(it.isChecked) + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(it.isChecked) + true + } + + R.id.menu_touchscreen -> { + it.isChecked = !it.isChecked + BooleanSetting.TOUCHSCREEN.setBoolean(it.isChecked) + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun startConfiguringControls() { + // Lock the current orientation to prevent editing inconsistencies + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } + } + } + binding.doneControlConfig.setVisible(true) + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + private fun stopConfiguringControls() { + binding.doneControlConfig.setVisible(false) + binding.surfaceInputOverlay.setIsInEditMode(false) + // Unlock the orientation if it was locked for editing + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + NativeConfig.saveGlobalConfig() + } + + @SuppressLint("SetTextI18n") + private fun adjustOverlay() { + val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) + adjustBinding.apply { + inputScaleSlider.apply { + valueTo = 150F + value = IntSetting.OVERLAY_SCALE.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + } + ) + } + inputOpacitySlider.apply { + valueTo = 100F + value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputOpacityValue.text = "${value.toInt()}%" + setControlOpacity(value.toInt()) + } + ) + } + inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" + inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_control_adjust) + .setView(adjustBinding.root) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + NativeConfig.saveGlobalConfig() + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + setControlOpacity(100) + } + .show() + } + + private fun setControlScale(scale: Int) { + IntSetting.OVERLAY_SCALE.setInt(scale) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setControlOpacity(opacity: Int) { + IntSetting.OVERLAY_OPACITY.setInt(opacity) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.inGameMenu + ) { v: View, windowInsets: WindowInsetsCompat -> + val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + var left = 0 + var right = 0 + if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = cutInsets.left + } else { + right = cutInsets.right + } + + v.updatePadding(left = left, top = cutInsets.top, right = right) + windowInsets + } + } + + private class EmulationState( + private val gamePath: String, + private val emulationCanStart: () -> Boolean + ) { + private var state: State + private var surface: Surface? = null + lateinit var emulationThread: Thread + + init { + // Starting state is stopped. + state = State.STOPPED + } + + @get:Synchronized + val isStopped: Boolean + get() = state == State.STOPPED + + // Getters for the current state + @get:Synchronized + val isPaused: Boolean + get() = state == State.PAUSED + + @get:Synchronized + val isRunning: Boolean + get() = state == State.RUNNING + + @Synchronized + fun stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation.") + NativeLibrary.stopEmulation() + state = State.STOPPED + } else { + Log.warning("[EmulationFragment] Stop called while already stopped.") + } + } + + // State changing methods + @Synchronized + fun pause() { + if (state != State.PAUSED) { + Log.debug("[EmulationFragment] Pausing emulation.") + + NativeLibrary.pauseEmulation() + + state = State.PAUSED + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean, programIndex: Int = 0) { + 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 (surface != null) { + runWithValidSurface(programIndex) + } + } + + @Synchronized + fun changeProgram(programIndex: Int) { + emulationThread.join() + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, false) + }, "NativeEmulation") + emulationThread.start() + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (this.surface != null) { + runWithValidSurface() + } + } + + @Synchronized + fun updateSurface() { + if (surface != null) { + NativeLibrary.surfaceChanged(surface) + } + } + + @Synchronized + fun clearSurface() { + if (surface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + } else { + surface = null + Log.debug("[EmulationFragment] Surface destroyed.") + when (state) { + State.RUNNING -> { + state = State.PAUSED + } + + State.PAUSED -> Log.warning( + "[EmulationFragment] Surface cleared while emulation paused." + ) + + else -> Log.warning( + "[EmulationFragment] Surface cleared while emulation stopped." + ) + } + } + } + + private fun runWithValidSurface(programIndex: Int = 0) { + NativeLibrary.surfaceChanged(surface) + if (!emulationCanStart.invoke()) { + return + } + + when (state) { + State.STOPPED -> { + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, true) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unpauseEmulation() + } + + else -> Log.debug("[EmulationFragment] Bug, run called while already running.") + } + state = State.RUNNING + } + + private enum class State { + STOPPED, RUNNING, PAUSED + } + } + + companion object { + private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) + private val thermalStatsUpdateHandler = Handler(Looper.myLooper()!!) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt new file mode 100644 index 0000000..b75bd53 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.DialogFolderPropertiesBinding +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.utils.NativeConfig +import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable + +class GameFolderPropertiesDialogFragment : DialogFragment() { + private val gamesViewModel: GamesViewModel by activityViewModels() + + private var deepScan = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) + val gameDir = requireArguments().parcelable(GAME_DIR)!! + + // Restore checkbox state + binding.deepScanSwitch.isChecked = + savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan + + // Ensure that we can get the checkbox state even if the view is destroyed + deepScan = binding.deepScanSwitch.isChecked + binding.deepScanSwitch.setOnClickListener { + deepScan = binding.deepScanSwitch.isChecked + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.game_folder_properties) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) + if (folderIndex != -1) { + gamesViewModel.folders.value[folderIndex].deepScan = + binding.deepScanSwitch.isChecked + gamesViewModel.updateGameDirs() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onStop() { + super.onStop() + NativeConfig.saveGlobalConfig() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(DEEP_SCAN, deepScan) + } + + companion object { + const val TAG = "GameFolderPropertiesDialogFragment" + + private const val GAME_DIR = "GameDir" + + private const val DEEP_SCAN = "DeepScan" + + fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment { + val args = Bundle() + args.putParcelable(GAME_DIR, gameDir) + val fragment = GameFolderPropertiesDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt new file mode 100644 index 0000000..9c13afe --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/GameInfoFragment.kt @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.FragmentGameInfoBinding +import org.sudachi.sudachi_emu.model.GameVerificationResult +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.GameMetadata +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class GameInfoFragment : Fragment() { + private var _binding: FragmentGameInfoBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + // Check for an up-to-date version string + args.game.version = GameMetadata.getVersion(args.game.path, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGameInfoBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.apply { + toolbarInfo.title = args.game.title + toolbarInfo.setNavigationOnClickListener { + view.findNavController().popBackStack() + } + + val pathString = Uri.parse(args.game.path).path ?: "" + path.setHint(R.string.path) + pathField.setText(pathString) + pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } + + programId.setHint(R.string.program_id) + programIdField.setText(args.game.programIdHex) + programIdField.setOnClickListener { + copyToClipboard(getString(R.string.program_id), args.game.programIdHex) + } + + if (args.game.developer.isNotEmpty()) { + developer.setHint(R.string.developer) + developerField.setText(args.game.developer) + developerField.setOnClickListener { + copyToClipboard(getString(R.string.developer), args.game.developer) + } + } else { + developer.setVisible(false) + } + + version.setHint(R.string.version) + versionField.setText(args.game.version) + versionField.setOnClickListener { + copyToClipboard(getString(R.string.version), args.game.version) + } + + buttonCopy.setOnClickListener { + val details = """ + ${args.game.title} + ${getString(R.string.path)} - $pathString + ${getString(R.string.program_id)} - ${args.game.programIdHex} + ${getString(R.string.developer)} - ${args.game.developer} + ${getString(R.string.version)} - ${args.game.version} + """.trimIndent() + copyToClipboard(args.game.title, details) + } + + buttonVerifyIntegrity.setOnClickListener { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.verifying, + true + ) { progressCallback, _ -> + val result = GameVerificationResult.from( + NativeLibrary.verifyGameContents( + args.game.path, + progressCallback + ) + ) + return@newInstance when (result) { + GameVerificationResult.Success -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_success, + descriptionId = R.string.operation_completed_successfully + ) + + GameVerificationResult.Failed -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_failure, + descriptionId = R.string.verify_failure_description + ) + + GameVerificationResult.NotImplemented -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_no_result, + descriptionId = R.string.verify_no_result_description + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + } + + setInsets() + } + + private fun copyToClipboard(label: String, body: String) { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, body) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarInfo.updateMargins(left = leftInsets, right = rightInsets) + binding.scrollInfo.updateMargins(left = leftInsets, right = rightInsets) + + binding.contentInfo.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 0000000..a525bd3 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.sudachi.sudachi_emu.databinding.DialogLicenseBinding +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.SerializableHelper.parcelable + +class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogLicenseBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogLicenseBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable(LICENSE)!! + + binding.apply { + textTitle.setText(license.titleId) + textLink.setText(license.linkId) + textCopyright.setText(license.copyrightId) + textLicense.setText(license.licenseId) + } + } + + companion object { + const val TAG = "LicenseBottomSheetDialogFragment" + + const val LICENSE = "License" + + fun newInstance( + license: License + ): LicenseBottomSheetDialogFragment { + val dialog = LicenseBottomSheetDialogFragment() + val bundle = Bundle() + bundle.putParcelable(LICENSE, license) + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt new file mode 100644 index 0000000..76dec47 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/LicensesFragment.kt @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.adapters.LicenseAdapter +import org.sudachi.sudachi_emu.databinding.FragmentLicensesBinding +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.License +import org.sudachi.sudachi_emu.utils.ViewUtils.updateMargins + +class LicensesFragment : Fragment() { + private var _binding: FragmentLicensesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLicensesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarLicenses.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val licenses = listOf( + License( + R.string.license_fidelityfx_fsr, + R.string.license_fidelityfx_fsr_description, + R.string.license_fidelityfx_fsr_link, + R.string.license_fidelityfx_fsr_copyright, + R.string.license_fidelityfx_fsr_text + ), + License( + R.string.license_cubeb, + R.string.license_cubeb_description, + R.string.license_cubeb_link, + R.string.license_cubeb_copyright, + R.string.license_cubeb_text + ), + License( + R.string.license_dynarmic, + R.string.license_dynarmic_description, + R.string.license_dynarmic_link, + R.string.license_dynarmic_copyright, + R.string.license_dynarmic_text + ), + License( + R.string.license_ffmpeg, + R.string.license_ffmpeg_description, + R.string.license_ffmpeg_link, + R.string.license_ffmpeg_copyright, + R.string.license_ffmpeg_text + ), + License( + R.string.license_opus, + R.string.license_opus_description, + R.string.license_opus_link, + R.string.license_opus_copyright, + R.string.license_opus_text + ), + License( + R.string.license_sirit, + R.string.license_sirit_description, + R.string.license_sirit_link, + R.string.license_sirit_copyright, + R.string.license_sirit_text + ), + License( + R.string.license_adreno_tools, + R.string.license_adreno_tools_description, + R.string.license_adreno_tools_link, + R.string.license_adreno_tools_copyright, + R.string.license_adreno_tools_text + ) + ) + + binding.listLicenses.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarLicenses.updateMargins(left = leftInsets, right = rightInsets) + binding.listLicenses.updateMargins(left = leftInsets, right = rightInsets) + + binding.listLicenses.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100644 index 0000000..33ccdfb --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/PermissionDeniedDialogFragment.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R + +class PermissionDeniedDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int -> + openSettings() + } + .setNegativeButton(android.R.string.cancel, null) + .setTitle(R.string.permission_denied) + .setMessage(R.string.permission_denied_description) + .show() + } + + private fun openSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", requireActivity().packageName, null) + intent.data = uri + startActivity(intent) + } + + companion object { + const val TAG = "PermissionDeniedDialogFragment" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100644 index 0000000..8a82475 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/ResetSettingsDialogFragment.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.features.settings.ui.SettingsActivity + +class ResetSettingsDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val settingsActivity = requireActivity() as SettingsActivity + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.reset_all_settings) + .setMessage(R.string.reset_all_settings_description) + .setPositiveButton(android.R.string.ok) { _, _ -> + settingsActivity.onSettingsReset() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + const val TAG = "ResetSettingsDialogFragment" + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt new file mode 100644 index 0000000..803e00c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/fragments/SearchFragment.kt @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.fragments + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler +import java.util.Locale +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.adapters.GameAdapter +import org.sudachi.sudachi_emu.databinding.FragmentSearchBinding +import org.sudachi.sudachi_emu.layout.AutofitGridLayoutManager +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import org.sudachi.sudachi_emu.utils.collect + +class SearchFragment : Fragment() { + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var preferences: SharedPreferences + + companion object { + private const val SEARCH_TEXT = "SearchText" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + preferences = PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + binding.gridGamesSearch.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } + + binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> + binding.clearButton.setVisible(text.toString().isNotEmpty()) + filterAndSearch() + } + + gamesViewModel.searchFocused.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setSearchFocused(false) } + ) { if (it) focusSearch() } + gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() } + gamesViewModel.searchedGames.collect(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + binding.noResultsView.setVisible(it.isNotEmpty()) + } + + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + + binding.searchBackground.setOnClickListener { focusSearch() } + + setInsets() + filterAndSearch() + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun filterAndSearch() { + val baseList = gamesViewModel.games.value + val filteredList: List = when (binding.chipGroup.checkedChipId) { + R.id.chip_recently_played -> { + baseList.filter { + val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) + lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } + } + + R.id.chip_homebrew -> baseList.filter { it.isHomebrew } + + R.id.chip_retail -> baseList.filter { !it.isHomebrew } + + else -> baseList + } + + if (binding.searchText.text.toString().isEmpty() && + binding.chipGroup.checkedChipId != View.NO_ID + ) { + gamesViewModel.setSearchedGames(filteredList) + return + } + + val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) + val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() + val sortedList: List = filteredList.mapNotNull { game -> + val title = game.title.lowercase(Locale.getDefault()) + val score = searchAlgorithm.similarity(searchTerm, title) + if (score > 0.03) { + ScoredGame(score, game) + } else { + null + } + }.sortedByDescending { it.score }.map { it.item } + gamesViewModel.setSearchedGames(sortedList) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + } + + private fun focusSearch() { + if (_binding != null) { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + binding.constraintSearch.updatePadding( + left = barInsets.left + cutoutInsets.left, + top = barInsets.top, + right = barInsets.right + cutoutInsets.right + ) + + binding.gridGamesSearch.updatePadding( + top = extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) + + val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.frameSearch.updatePadding(left = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) + binding.noResultsView.updatePadding(left = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing + spacingNavigationRail, + right = chipSpacing + ) + mlpDivider.leftMargin = chipSpacing + spacingNavigationRail + mlpDivider.rightMargin = chipSpacing + } else { + binding.frameSearch.updatePadding(right = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) + binding.noResultsView.updatePadding(right = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing, + right = chipSpacing + spacingNavigationRail + ) + mlpDivider.leftMargin = chipSpacing + mlpDivider.rightMargin = chipSpacing + spacingNavigationRail + } + binding.divider.layoutParams = mlpDivider + + windowInsets + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt new file mode 100644 index 0000000..aedda0e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/layout/AutofitGridLayoutManager.kt @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.layout + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import org.sudachi.sudachi_emu.R + +/** + * Cut down version of the solution provided here + * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count + */ +class AutofitGridLayoutManager( + context: Context, + columnWidth: Int +) : GridLayoutManager(context, 1) { + private var columnWidth = 0 + private var isColumnWidthChanged = true + private var lastWidth = 0 + private var lastHeight = 0 + + init { + setColumnWidth(checkedColumnWidth(context, columnWidth)) + } + + private fun checkedColumnWidth(context: Context, columnWidth: Int): Int { + var newColumnWidth = columnWidth + if (newColumnWidth <= 0) { + newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) + } + return newColumnWidth + } + + private fun setColumnWidth(newColumnWidth: Int) { + if (newColumnWidth > 0 && newColumnWidth != columnWidth) { + columnWidth = newColumnWidth + isColumnWidthChanged = true + } + } + + override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) { + val width = width + val height = height + if (columnWidth > 0 && width > 0 && height > 0 && + (isColumnWidthChanged || lastWidth != width || lastHeight != height) + ) { + val totalSpace: Int = if (orientation == VERTICAL) { + width - paddingRight - paddingLeft + } else { + height - paddingTop - paddingBottom + } + val spanCount = 1.coerceAtLeast(totalSpace / columnWidth) + setSpanCount(spanCount) + isColumnWidthChanged = false + } + lastWidth = width + lastHeight = height + super.onLayoutChildren(recycler, state) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt new file mode 100644 index 0000000..b83ad2f --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/AddonViewModel.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class AddonViewModel : ViewModel() { + private val _patchList = MutableStateFlow(mutableListOf()) + val addonList get() = _patchList.asStateFlow() + + private val _showModInstallPicker = MutableStateFlow(false) + val showModInstallPicker get() = _showModInstallPicker.asStateFlow() + + private val _showModNoticeDialog = MutableStateFlow(false) + val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() + + private val _addonToDelete = MutableStateFlow(null) + val addonToDelete = _addonToDelete.asStateFlow() + + var game: Game? = null + + private val isRefreshing = AtomicBoolean(false) + + fun onOpenAddons(game: Game) { + this.game = game + refreshAddons() + } + + fun refreshAddons() { + if (isRefreshing.get() || game == null) { + return + } + isRefreshing.set(true) + viewModelScope.launch { + withContext(Dispatchers.IO) { + val patchList = ( + NativeLibrary.getPatchesForFile(game!!.path, game!!.programId) + ?: emptyArray() + ).toMutableList() + patchList.sortBy { it.name } + _patchList.value = patchList + isRefreshing.set(false) + } + } + } + + fun setAddonToDelete(patch: Patch?) { + _addonToDelete.value = patch + } + + fun onDeleteAddon(patch: Patch) { + when (PatchType.from(patch.type)) { + PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) + PatchType.DLC -> NativeLibrary.removeDLC(patch.programId) + PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name) + } + refreshAddons() + } + + fun onCloseAddons() { + if (_patchList.value.isEmpty()) { + return + } + + NativeConfig.setDisabledAddons( + game!!.programId, + _patchList.value.mapNotNull { + if (it.enabled) { + null + } else { + it.name + } + }.toTypedArray() + ) + NativeConfig.saveGlobalConfig() + _patchList.value.clear() + game = null + } + + fun showModInstallPicker(install: Boolean) { + _showModInstallPicker.value = install + } + + fun showModNoticeDialog(show: Boolean) { + _showModNoticeDialog.value = show + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt new file mode 100644 index 0000000..a7c9aef --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Applet.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.sudachi.sudachi_emu.R + +data class Applet( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + @DrawableRes val iconId: Int, + val appletInfo: AppletInfo, + val cabinetMode: CabinetMode = CabinetMode.None +) + +// Combination of Common::AM::Applets::AppletId enum and the entry id +enum class AppletInfo(val appletId: Int, val entryId: Long = 0) { + None(0x00), + Application(0x01), + OverlayDisplay(0x02), + QLaunch(0x03), + Starter(0x04), + Auth(0x0A), + Cabinet(0x0B, 0x0100000000001002), + Controller(0x0C), + DataErase(0x0D), + Error(0x0E), + NetConnect(0x0F), + ProfileSelect(0x10), + SoftwareKeyboard(0x11), + MiiEdit(0x12, 0x0100000000001009), + Web(0x13), + Shop(0x14), + PhotoViewer(0x015, 0x010000000000100D), + Settings(0x16), + OfflineWeb(0x17), + LoginShare(0x18), + WebAuth(0x19), + MyPage(0x1A) +} + +// Matches enum in Service::NFP::CabinetMode with extra metadata +enum class CabinetMode( + val id: Int, + @StringRes val titleId: Int = 0, + @DrawableRes val iconId: Int = 0 +) { + None(-1), + StartNicknameAndOwnerSettings(0, R.string.cabinet_nickname_and_owner, R.drawable.ic_edit), + StartGameDataEraser(1, R.string.cabinet_game_data_eraser, R.drawable.ic_refresh), + StartRestorer(2, R.string.cabinet_restorer, R.drawable.ic_restore), + StartFormatter(3, R.string.cabinet_formatter, R.drawable.ic_clear) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt new file mode 100644 index 0000000..756fa25 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Driver.kt @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import org.sudachi.sudachi_emu.utils.GpuDriverMetadata + +data class Driver( + override var selected: Boolean, + val title: String, + val version: String = "", + val description: String = "" +) : SelectableItem { + override fun onSelectionStateChanged(selected: Boolean) { + this.selected = selected + } + + companion object { + fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver = + Driver( + selected, + this.name ?: "", + this.version ?: "", + this.description ?: "" + ) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt new file mode 100644 index 0000000..eb3c532 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/GamesViewModel.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.utils.GameHelper +import org.sudachi.sudachi_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class GamesViewModel : ViewModel() { + val games: StateFlow> get() = _games + private val _games = MutableStateFlow(emptyList()) + + val searchedGames: StateFlow> get() = _searchedGames + private val _searchedGames = MutableStateFlow(emptyList()) + + val isReloading: StateFlow get() = _isReloading + private val _isReloading = MutableStateFlow(false) + + private val reloading = AtomicBoolean(false) + + val shouldSwapData: StateFlow get() = _shouldSwapData + private val _shouldSwapData = MutableStateFlow(false) + + val shouldScrollToTop: StateFlow get() = _shouldScrollToTop + private val _shouldScrollToTop = MutableStateFlow(false) + + val searchFocused: StateFlow get() = _searchFocused + private val _searchFocused = MutableStateFlow(false) + + private val _folders = MutableStateFlow(mutableListOf()) + val folders = _folders.asStateFlow() + + init { + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + getGameDirs() + reloadGames(directoriesChanged = false, firstStartup = true) + } + + fun setGames(games: List) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.value = sortedList + } + + fun setSearchedGames(games: List) { + _searchedGames.value = games + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.value = shouldSwap + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.value = shouldScroll + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.value = searchFocused + } + + fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) { + if (reloading.get()) { + return + } + reloading.set(true) + _isReloading.value = true + + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (firstStartup) { + // Retrieve list of cached games + val storedGames = + PreferenceManager.getDefaultSharedPreferences(SudachiApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game + try { + game = Json.decodeFromString(it) + } catch (e: Exception) { + // We don't care about any errors related to parsing the game cache + return@forEach + } + + val gameExists = + DocumentFile.fromSingleUri( + SudachiApplication.appContext, + Uri.parse(game.path) + )?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + } + + setGames(GameHelper.getGames()) + reloading.set(false) + _isReloading.value = false + + if (directoriesChanged) { + setShouldSwapData(true) + } + } + } + } + + fun addFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.addGameDir(gameDir) + getGameDirs(true) + } + } + + fun removeFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + val gameDirs = _folders.value.toMutableList() + val removedDirIndex = gameDirs.indexOf(gameDir) + if (removedDirIndex != -1) { + gameDirs.removeAt(removedDirIndex) + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + getGameDirs() + } + } + } + + fun updateGameDirs() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.setGameDirs(_folders.value.toTypedArray()) + getGameDirs() + } + } + + fun onOpenGameFoldersFragment() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs() + } + } + + fun onCloseGameFoldersFragment() { + NativeConfig.saveGlobalConfig() + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs(true) + } + } + } + + private fun getGameDirs(reloadList: Boolean = false) { + val gameDirs = NativeConfig.getGameDirs() + _folders.value = gameDirs.toMutableList() + if (reloadList) { + reloadGames(true) + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt new file mode 100644 index 0000000..dc9aa5e --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeSetting.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class HomeSetting( + val titleId: Int, + val descriptionId: Int, + val iconId: Int, + val onClick: () -> Unit, + val isEnabled: () -> Boolean = { true }, + val disabledTitleId: Int = 0, + val disabledMessageId: Int = 0, + val details: StateFlow = MutableStateFlow("") +) diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt new file mode 100644 index 0000000..d003e36 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/HomeViewModel.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.net.Uri +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class HomeViewModel : ViewModel() { + val navigationVisible: StateFlow> get() = _navigationVisible + private val _navigationVisible = MutableStateFlow(Pair(false, false)) + + val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible + private val _statusBarShadeVisible = MutableStateFlow(true) + + val shouldPageForward: StateFlow get() = _shouldPageForward + private val _shouldPageForward = MutableStateFlow(false) + + private val _gamesDirSelected = MutableStateFlow(false) + val gamesDirSelected get() = _gamesDirSelected.asStateFlow() + + private val _openImportSaves = MutableStateFlow(false) + val openImportSaves get() = _openImportSaves.asStateFlow() + + private val _contentToInstall = MutableStateFlow?>(null) + val contentToInstall get() = _contentToInstall.asStateFlow() + + private val _reloadPropertiesList = MutableStateFlow(false) + val reloadPropertiesList get() = _reloadPropertiesList.asStateFlow() + + private val _checkKeys = MutableStateFlow(false) + val checkKeys = _checkKeys.asStateFlow() + + var navigatedToSetup = false + + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { + if (navigationVisible.value.first == visible) { + return + } + _navigationVisible.value = Pair(visible, animated) + } + + fun setStatusBarShadeVisibility(visible: Boolean) { + if (statusBarShadeVisible.value == visible) { + return + } + _statusBarShadeVisible.value = visible + } + + fun setShouldPageForward(pageForward: Boolean) { + _shouldPageForward.value = pageForward + } + + fun setGamesDirSelected(selected: Boolean) { + _gamesDirSelected.value = selected + } + + fun setOpenImportSaves(import: Boolean) { + _openImportSaves.value = import + } + + fun setContentToInstall(documents: List?) { + _contentToInstall.value = documents + } + + fun reloadPropertiesList(reload: Boolean) { + _reloadPropertiesList.value = reload + } + + fun setCheckKeys(value: Boolean) { + _checkKeys.value = value + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt new file mode 100644 index 0000000..c736305 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/InstallResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +enum class InstallResult(val int: Int) { + Success(0), + Overwrite(1), + Failure(2), + BaseInstallAttempted(3); + + companion object { + fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt new file mode 100644 index 0000000..24f2800 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/License.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class License( + val titleId: Int, + val descriptionId: Int, + val linkId: Int, + val copyrightId: Int, + val licenseId: Int +) : Parcelable diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt new file mode 100644 index 0000000..b556efb --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/Patch.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +import androidx.annotation.Keep + +@Keep +data class Patch( + var enabled: Boolean, + val name: String, + val version: String, + val type: Int, + val programId: String, + val titleId: String +) diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt new file mode 100644 index 0000000..305d75d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/PatchType.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +enum class PatchType(val int: Int) { + Update(0), + DLC(1), + Mod(2); + + companion object { + fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt new file mode 100644 index 0000000..c24819a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/model/SelectableItem.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.model + +interface SelectableItem { + var selected: Boolean + fun onSelectionStateChanged(selected: Boolean) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 0000000..4c5bc1a --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_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.sudachi.sudachi_emu.features.input.NativeInput.ButtonState +import org.sudachi.sudachi_emu.features.input.model.NativeButton +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. + * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. + * @param button [NativeButton] for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val button: NativeButton, + val overlayControlData: OverlayControlData +) { + // The ID value what motion event is tracking + var trackId: Int + + // The drawable position on the screen + private var buttonPositionX = 0 + private var buttonPositionY = 0 + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedStateBitmap: BitmapDrawable + private var pressedState = false + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap) + trackId = -1 + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + } + + /** + * Updates button status based on the motion event. + * + * @return true if value was changed + */ + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + trackId = pointerId + return true + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + trackId = -1 + return true + } + + return false + } + + fun setPosition(x: Int, y: Int) { + buttonPositionX = x + buttonPositionY = y + } + + fun draw(canvas: Canvas?) { + currentStateBitmapDrawable.draw(canvas!!) + } + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateBitmap else defaultStateBitmap + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedStateBitmap.alpha = value + } + + val status: Int + get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED + val bounds: Rect + get() = defaultStateBitmap.bounds +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 0000000..82ba281 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_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.sudachi.sudachi_emu.features.input.NativeInput.ButtonState +import org.sudachi.sudachi_emu.features.input.model.NativeButton + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] of the default state. + * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap +) { + /** + * Gets one of the InputOverlayDrawableDpad's button IDs. + * + * @return the requested InputOverlayDrawableDpad's button ID. + */ + // The ID identifying what type of button this Drawable represents. + val up = NativeButton.DUp + val down = NativeButton.DDown + val left = NativeButton.DLeft + val right = NativeButton.DRight + var trackId: Int + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedOneDirectionStateBitmap: BitmapDrawable + private val pressedTwoDirectionsStateBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + private var controlPositionX = 0 + private var controlPositionY = 0 + + private var upButtonState = false + private var downButtonState = false + private var leftButtonState = false + private var rightButtonState = false + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap) + this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + trackId = -1 + } + + fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + trackId = pointerId + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + trackId = -1 + upButtonState = false + downButtonState = false + leftButtonState = false + rightButtonState = false + return true + } + if (trackId == -1) { + return false + } + if (!dpad_slide && !isActionDown) { + return false + } + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = bounds.bottom.toFloat() + var maxX = bounds.right.toFloat() + touchX -= bounds.centerX().toFloat() + maxX -= bounds.centerX().toFloat() + touchY -= bounds.centerY().toFloat() + maxY -= bounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldUpState = upButtonState + val oldDownState = downButtonState + val oldLeftState = leftButtonState + val oldRightState = rightButtonState + + upButtonState = axisY < -VIRT_AXIS_DEADZONE + downButtonState = axisY > VIRT_AXIS_DEADZONE + leftButtonState = axisX < -VIRT_AXIS_DEADZONE + rightButtonState = axisX > VIRT_AXIS_DEADZONE + return oldUpState != upButtonState || + oldDownState != downButtonState || + oldLeftState != leftButtonState || + oldRightState != rightButtonState + } + return false + } + + fun draw(canvas: Canvas) { + val px = controlPositionX + width / 2 + val py = controlPositionY + height / 2 + + // Pressed up + if (upButtonState && !leftButtonState && !rightButtonState) { + pressedOneDirectionStateBitmap.draw(canvas) + return + } + + // Pressed down + if (downButtonState && !leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed left + if (leftButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed right + if (rightButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed up left + if (upButtonState && leftButtonState && !rightButtonState) { + pressedTwoDirectionsStateBitmap.draw(canvas) + return + } + + // Pressed up right + if (upButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down right + if (downButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down left + if (downButtonState && leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Not pressed + defaultStateBitmap.draw(canvas) + } + + val upStatus: Int + get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val downStatus: Int + get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val leftStatus: Int + get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val rightStatus: Int + get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom) + pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedOneDirectionStateBitmap.alpha = value + pressedTwoDirectionsStateBitmap.alpha = value + } + + val bounds: Rect + get() = defaultStateBitmap.bounds + + companion object { + const val VIRT_AXIS_DEADZONE = 0.5f + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt new file mode 100644 index 0000000..d0d1630 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControl.kt @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +import androidx.annotation.IntegerRes +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication + +enum class OverlayControl( + val id: String, + val defaultVisibility: Boolean, + @IntegerRes val defaultLandscapePositionResources: Pair, + @IntegerRes val defaultPortraitPositionResources: Pair, + @IntegerRes val defaultFoldablePositionResources: Pair +) { + BUTTON_A( + "button_a", + true, + Pair(R.integer.BUTTON_A_X, R.integer.BUTTON_A_Y), + Pair(R.integer.BUTTON_A_X_PORTRAIT, R.integer.BUTTON_A_Y_PORTRAIT), + Pair(R.integer.BUTTON_A_X_FOLDABLE, R.integer.BUTTON_A_Y_FOLDABLE) + ), + BUTTON_B( + "button_b", + true, + Pair(R.integer.BUTTON_B_X, R.integer.BUTTON_B_Y), + Pair(R.integer.BUTTON_B_X_PORTRAIT, R.integer.BUTTON_B_Y_PORTRAIT), + Pair(R.integer.BUTTON_B_X_FOLDABLE, R.integer.BUTTON_B_Y_FOLDABLE) + ), + BUTTON_X( + "button_x", + true, + Pair(R.integer.BUTTON_X_X, R.integer.BUTTON_X_Y), + Pair(R.integer.BUTTON_X_X_PORTRAIT, R.integer.BUTTON_X_Y_PORTRAIT), + Pair(R.integer.BUTTON_X_X_FOLDABLE, R.integer.BUTTON_X_Y_FOLDABLE) + ), + BUTTON_Y( + "button_y", + true, + Pair(R.integer.BUTTON_Y_X, R.integer.BUTTON_Y_Y), + Pair(R.integer.BUTTON_Y_X_PORTRAIT, R.integer.BUTTON_Y_Y_PORTRAIT), + Pair(R.integer.BUTTON_Y_X_FOLDABLE, R.integer.BUTTON_Y_Y_FOLDABLE) + ), + BUTTON_PLUS( + "button_plus", + true, + Pair(R.integer.BUTTON_PLUS_X, R.integer.BUTTON_PLUS_Y), + Pair(R.integer.BUTTON_PLUS_X_PORTRAIT, R.integer.BUTTON_PLUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_PLUS_X_FOLDABLE, R.integer.BUTTON_PLUS_Y_FOLDABLE) + ), + BUTTON_MINUS( + "button_minus", + true, + Pair(R.integer.BUTTON_MINUS_X, R.integer.BUTTON_MINUS_Y), + Pair(R.integer.BUTTON_MINUS_X_PORTRAIT, R.integer.BUTTON_MINUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_MINUS_X_FOLDABLE, R.integer.BUTTON_MINUS_Y_FOLDABLE) + ), + BUTTON_HOME( + "button_home", + false, + Pair(R.integer.BUTTON_HOME_X, R.integer.BUTTON_HOME_Y), + Pair(R.integer.BUTTON_HOME_X_PORTRAIT, R.integer.BUTTON_HOME_Y_PORTRAIT), + Pair(R.integer.BUTTON_HOME_X_FOLDABLE, R.integer.BUTTON_HOME_Y_FOLDABLE) + ), + BUTTON_CAPTURE( + "button_capture", + false, + Pair(R.integer.BUTTON_CAPTURE_X, R.integer.BUTTON_CAPTURE_Y), + Pair(R.integer.BUTTON_CAPTURE_X_PORTRAIT, R.integer.BUTTON_CAPTURE_Y_PORTRAIT), + Pair(R.integer.BUTTON_CAPTURE_X_FOLDABLE, R.integer.BUTTON_CAPTURE_Y_FOLDABLE) + ), + BUTTON_L( + "button_l", + true, + Pair(R.integer.BUTTON_L_X, R.integer.BUTTON_L_Y), + Pair(R.integer.BUTTON_L_X_PORTRAIT, R.integer.BUTTON_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_L_X_FOLDABLE, R.integer.BUTTON_L_Y_FOLDABLE) + ), + BUTTON_R( + "button_r", + true, + Pair(R.integer.BUTTON_R_X, R.integer.BUTTON_R_Y), + Pair(R.integer.BUTTON_R_X_PORTRAIT, R.integer.BUTTON_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_R_X_FOLDABLE, R.integer.BUTTON_R_Y_FOLDABLE) + ), + BUTTON_ZL( + "button_zl", + true, + Pair(R.integer.BUTTON_ZL_X, R.integer.BUTTON_ZL_Y), + Pair(R.integer.BUTTON_ZL_X_PORTRAIT, R.integer.BUTTON_ZL_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZL_X_FOLDABLE, R.integer.BUTTON_ZL_Y_FOLDABLE) + ), + BUTTON_ZR( + "button_zr", + true, + Pair(R.integer.BUTTON_ZR_X, R.integer.BUTTON_ZR_Y), + Pair(R.integer.BUTTON_ZR_X_PORTRAIT, R.integer.BUTTON_ZR_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZR_X_FOLDABLE, R.integer.BUTTON_ZR_Y_FOLDABLE) + ), + BUTTON_STICK_L( + "button_stick_l", + true, + Pair(R.integer.BUTTON_STICK_L_X, R.integer.BUTTON_STICK_L_Y), + Pair(R.integer.BUTTON_STICK_L_X_PORTRAIT, R.integer.BUTTON_STICK_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_L_X_FOLDABLE, R.integer.BUTTON_STICK_L_Y_FOLDABLE) + ), + BUTTON_STICK_R( + "button_stick_r", + true, + Pair(R.integer.BUTTON_STICK_R_X, R.integer.BUTTON_STICK_R_Y), + Pair(R.integer.BUTTON_STICK_R_X_PORTRAIT, R.integer.BUTTON_STICK_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_R_X_FOLDABLE, R.integer.BUTTON_STICK_R_Y_FOLDABLE) + ), + STICK_L( + "stick_l", + true, + Pair(R.integer.STICK_L_X, R.integer.STICK_L_Y), + Pair(R.integer.STICK_L_X_PORTRAIT, R.integer.STICK_L_Y_PORTRAIT), + Pair(R.integer.STICK_L_X_FOLDABLE, R.integer.STICK_L_Y_FOLDABLE) + ), + STICK_R( + "stick_r", + true, + Pair(R.integer.STICK_R_X, R.integer.STICK_R_Y), + Pair(R.integer.STICK_R_X_PORTRAIT, R.integer.STICK_R_Y_PORTRAIT), + Pair(R.integer.STICK_R_X_FOLDABLE, R.integer.STICK_R_Y_FOLDABLE) + ), + COMBINED_DPAD( + "combined_dpad", + true, + Pair(R.integer.COMBINED_DPAD_X, R.integer.COMBINED_DPAD_Y), + Pair(R.integer.COMBINED_DPAD_X_PORTRAIT, R.integer.COMBINED_DPAD_Y_PORTRAIT), + Pair(R.integer.COMBINED_DPAD_X_FOLDABLE, R.integer.COMBINED_DPAD_Y_FOLDABLE) + ); + + fun getDefaultPositionForLayout(layout: OverlayLayout): Pair { + val rawResourcePair: Pair + SudachiApplication.appContext.resources.apply { + rawResourcePair = when (layout) { + OverlayLayout.Landscape -> { + Pair( + getInteger(this@OverlayControl.defaultLandscapePositionResources.first), + getInteger(this@OverlayControl.defaultLandscapePositionResources.second) + ) + } + + OverlayLayout.Portrait -> { + Pair( + getInteger(this@OverlayControl.defaultPortraitPositionResources.first), + getInteger(this@OverlayControl.defaultPortraitPositionResources.second) + ) + } + + OverlayLayout.Foldable -> { + Pair( + getInteger(this@OverlayControl.defaultFoldablePositionResources.first), + getInteger(this@OverlayControl.defaultFoldablePositionResources.second) + ) + } + } + } + + return Pair( + rawResourcePair.first.toDouble() / 1000, + rawResourcePair.second.toDouble() / 1000 + ) + } + + fun toOverlayControlData(): OverlayControlData = + OverlayControlData( + id, + defaultVisibility, + getDefaultPositionForLayout(OverlayLayout.Landscape), + getDefaultPositionForLayout(OverlayLayout.Portrait), + getDefaultPositionForLayout(OverlayLayout.Foldable) + ) + + companion object { + val map: HashMap by lazy { + val hashMap = hashMapOf() + entries.forEach { hashMap[it.id] = it } + hashMap + } + + fun from(id: String): OverlayControl? = map[id] + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt new file mode 100644 index 0000000..901187c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/overlay/model/OverlayControlData.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.overlay.model + +data class OverlayControlData( + val id: String, + var enabled: Boolean, + var landscapePosition: Pair, + var portraitPosition: Pair, + var foldablePosition: Pair +) { + fun positionFromLayout(layout: OverlayLayout): Pair = + when (layout) { + OverlayLayout.Landscape -> landscapePosition + OverlayLayout.Portrait -> portraitPosition + OverlayLayout.Foldable -> foldablePosition + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt new file mode 100644 index 0000000..1a84992 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/MainActivity.kt @@ -0,0 +1,692 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.ui.main + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager +import android.view.animation.PathInterpolator +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import androidx.preference.PreferenceManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.navigation.NavigationBarView +import java.io.File +import java.io.FilenameFilter +import org.sudachi.sudachi_emu.HomeNavigationDirections +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.databinding.ActivityMainBinding +import org.sudachi.sudachi_emu.features.settings.model.Settings +import org.sudachi.sudachi_emu.fragments.AddGameFolderDialogFragment +import org.sudachi.sudachi_emu.fragments.ProgressDialogFragment +import org.sudachi.sudachi_emu.fragments.MessageDialogFragment +import org.sudachi.sudachi_emu.model.AddonViewModel +import org.sudachi.sudachi_emu.model.DriverViewModel +import org.sudachi.sudachi_emu.model.GamesViewModel +import org.sudachi.sudachi_emu.model.HomeViewModel +import org.sudachi.sudachi_emu.model.InstallResult +import org.sudachi.sudachi_emu.model.TaskState +import org.sudachi.sudachi_emu.model.TaskViewModel +import org.sudachi.sudachi_emu.utils.* +import org.sudachi.sudachi_emu.utils.ViewUtils.setVisible +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +class MainActivity : AppCompatActivity(), ThemeProvider { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + private val taskViewModel: TaskViewModel by viewModels() + private val addonViewModel: AddonViewModel by viewModels() + private val driverViewModel: DriverViewModel by viewModels() + + override var themeId: Int = 0 + + private val CHECKED_DECRYPTION = "CheckedDecryption" + private var checkedDecryption = false + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } + + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (savedInstanceState != null) { + checkedDecryption = savedInstanceState.getBoolean(CHECKED_DECRYPTION) + } + if (!checkedDecryption) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + if (!firstTimeSetup) { + checkKeys() + } + checkedDecryption = true + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + + window.statusBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + window.navigationBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + + binding.statusBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + setUpNavigation(navHostFragment.navController) + (binding.navigationView as NavigationBarView).setOnItemReselectedListener { + when (it.itemId) { + R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) + R.id.searchFragment -> gamesViewModel.setSearchFocused(true) + R.id.homeSettingsFragment -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + navHostFragment.navController.navigate(action) + } + } + } + + // Prevents navigation from being drawn for a short time on recreation if set to hidden + if (!homeViewModel.navigationVisible.value.first) { + binding.navigationView.setVisible(visible = false, gone = false) + binding.statusBarShade.setVisible(visible = false, gone = false) + } + + homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) } + homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) } + homeViewModel.contentToInstall.collect( + this, + resetState = { homeViewModel.setContentToInstall(null) } + ) { + if (it != null) { + installContent(it) + } + } + homeViewModel.checkKeys.collect(this, resetState = { homeViewModel.setCheckKeys(false) }) { + if (it) checkKeys() + } + + setInsets() + } + + private fun checkKeys() { + if (!NativeLibrary.areKeysPresent()) { + MessageDialogFragment.newInstance( + titleId = R.string.keys_missing, + descriptionId = R.string.keys_missing_description, + helpLinkId = R.string.keys_missing_help + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(CHECKED_DECRYPTION, checkedDecryption) + } + + fun finishSetup(navController: NavController) { + navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + showNavigation(visible = true, animated = true) + } + + private fun setUpNavigation(navController: NavController) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + + if (firstTimeSetup && !homeViewModel.navigatedToSetup) { + navController.navigate(R.id.firstTimeSetupFragment) + homeViewModel.navigatedToSetup = true + } else { + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + } + } + + private fun showNavigation(visible: Boolean, animated: Boolean) { + if (!animated) { + binding.navigationView.setVisible(visible) + return + } + + val smallLayout = resources.getBoolean(R.bool.small_layout) + binding.navigationView.animate().apply { + if (visible) { + binding.navigationView.setVisible(true) + duration = 300 + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + + if (smallLayout) { + binding.navigationView.translationY = + binding.navigationView.height.toFloat() * 2 + translationY(0f) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * -2 + translationX(0f) + } else { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * 2 + translationX(0f) + } + } + } else { + duration = 300 + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + + if (smallLayout) { + translationY(binding.navigationView.height.toFloat() * 2) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + translationX(binding.navigationView.width.toFloat() * -2) + } else { + translationX(binding.navigationView.width.toFloat() * 2) + } + } + } + }.withEndAction { + if (!visible) { + binding.navigationView.setVisible(visible = false, gone = false) + } + }.start() + } + + private fun showStatusBarShade(visible: Boolean) { + binding.statusBarShade.animate().apply { + if (visible) { + binding.statusBarShade.setVisible(true) + binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2 + duration = 300 + translationY(0f) + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + } else { + duration = 300 + translationY(binding.navigationView.height.toFloat() * -2) + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + } + }.withEndAction { + if (!visible) { + binding.statusBarShade.setVisible(visible = false, gone = false) + } + }.start() + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams + mlpStatusShade.height = insets.top + binding.statusBarShade.layoutParams = mlpStatusShade + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = insets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + + override fun setTheme(resId: Int) { + super.setTheme(resId) + themeId = resId + } + + val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processGamesDir(result) + } + } + + fun processGamesDir(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + AddGameFolderDialogFragment.newInstance(uriString) + .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) + } + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + processKey(result) + } + } + + fun processKey(result: Uri): Boolean { + if (FileUtil.getExtension(result) != "keys") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_prod_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "prod.keys" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + homeViewModel.setCheckKeys(true) + gamesViewModel.reloadGames(true) + return true + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + } + return false + } + + val getFirmware = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } + + val firmwarePath = + File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") + val cacheFirmwareDir = File("${cacheDir.path}/registered/") + + ProgressDialogFragment.newInstance( + this, + R.string.firmware_installing + ) { progressCallback, _ -> + var messageToShow: Any + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheFirmwareDir, + progressCallback + ) + val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 + val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 + messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { + MessageDialogFragment.newInstance( + this, + titleId = R.string.firmware_installed_failure, + descriptionId = R.string.firmware_installed_failure_description + ) + } else { + firmwarePath.deleteRecursively() + cacheFirmwareDir.copyRecursively(firmwarePath, true) + NativeLibrary.initializeSystem(true) + homeViewModel.setCheckKeys(true) + getString(R.string.save_file_imported_success) + } + } catch (e: Exception) { + Log.error("[MainActivity] Firmware install failed - ${e.message}") + messageToShow = getString(R.string.fatal_error) + } finally { + cacheFirmwareDir.deleteRecursively() + } + messageToShow + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val getAmiiboKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + if (FileUtil.getExtension(result) != "bin") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_amiibo_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "key_retail.bin" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + } + + val installGameUpdate = registerForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { documents: List -> + if (documents.isEmpty()) { + return@registerForActivityResult + } + + if (addonViewModel.game == null) { + installContent(documents) + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.verifying_content, + false + ) { _, _ -> + var updatesMatchProgram = true + for (document in documents) { + val valid = NativeLibrary.doesUpdateMatchProgram( + addonViewModel.game!!.programId, + document.toString() + ) + if (!valid) { + updatesMatchProgram = false + break + } + } + + if (updatesMatchProgram) { + homeViewModel.setContentToInstall(documents) + } else { + MessageDialogFragment.newInstance( + this@MainActivity, + titleId = R.string.content_install_notice, + descriptionId = R.string.content_install_notice_description, + positiveAction = { homeViewModel.setContentToInstall(documents) }, + negativeAction = {} + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + private fun installContent(documents: List) { + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.installing_game_content + ) { progressCallback, messageCallback -> + var installSuccess = 0 + var installOverwrite = 0 + var errorBaseGame = 0 + var error = 0 + documents.forEach { + messageCallback.invoke(FileUtil.getFilename(it)) + when ( + InstallResult.from( + NativeLibrary.installFileToNand( + it.toString(), + progressCallback + ) + ) + ) { + InstallResult.Success -> { + installSuccess += 1 + } + + InstallResult.Overwrite -> { + installOverwrite += 1 + } + + InstallResult.BaseInstallAttempted -> { + errorBaseGame += 1 + } + + InstallResult.Failure -> { + error += 1 + } + } + } + + addonViewModel.refreshAddons() + + val separator = System.getProperty("line.separator") ?: "\n" + val installResult = StringBuilder() + if (installSuccess > 0) { + installResult.append( + getString( + R.string.install_game_content_success_install, + installSuccess + ) + ) + installResult.append(separator) + } + if (installOverwrite > 0) { + installResult.append( + getString( + R.string.install_game_content_success_overwrite, + installOverwrite + ) + ) + installResult.append(separator) + } + val errorTotal: Int = errorBaseGame + error + if (errorTotal > 0) { + installResult.append(separator) + installResult.append( + getString( + R.string.install_game_content_failed_count, + errorTotal + ) + ) + installResult.append(separator) + if (errorBaseGame > 0) { + installResult.append(separator) + installResult.append( + getString(R.string.install_game_content_failure_base) + ) + installResult.append(separator) + } + if (error > 0) { + installResult.append( + getString(R.string.install_game_content_failure_description) + ) + installResult.append(separator) + } + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_failure, + descriptionString = installResult.toString().trim(), + helpLinkId = R.string.install_game_content_help_link + ) + } else { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_success, + descriptionString = installResult.toString().trim() + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val exportUserData = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.exporting_user_data, + true + ) { progressCallback, _ -> + val zipResult = FileUtil.zipFromInternalStorage( + File(DirectoryInitialization.userDirectory!!), + DirectoryInitialization.userDirectory!!, + BufferedOutputStream(contentResolver.openOutputStream(result)), + progressCallback, + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.user_data_export_success) + TaskState.Failed -> R.string.export_failed + TaskState.Cancelled -> R.string.user_data_export_cancelled + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val importUserData = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.importing_user_data + ) { progressCallback, _ -> + val checkStream = + ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) + var isSudachiBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + val itemName = ze!!.name.trim() + if (itemName == "/config/config.ini" || itemName == "config/config.ini") { + isSudachiBackup = true + return@use + } + } + } + if (!isSudachiBackup) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_sudachi_backup, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Clear existing user data + NativeConfig.unloadGlobalConfig() + File(DirectoryInitialization.userDirectory!!).deleteRecursively() + + // Copy archive to internal storage + try { + FileUtil.unzipToInternalStorage( + result.toString(), + File(DirectoryInitialization.userDirectory!!), + progressCallback + ) + } catch (e: Exception) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.import_failed, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Reinitialize relevant data + NativeLibrary.initializeSystem(true) + NativeConfig.initializeGlobalConfig() + gamesViewModel.reloadGames(false) + driverViewModel.reloadDriverData() + + return@newInstance getString(R.string.user_data_import_success) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt new file mode 100644 index 0000000..baf1994 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/ui/main/ThemeProvider.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.ui.main + +interface ThemeProvider { + /** + * Provides theme ID by overriding an activity's 'setTheme' method and returning that result + */ + var themeId: Int +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt new file mode 100644 index 0000000..0c56d8d --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/FileUtil.kt @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URLDecoder +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.MinimalDocumentFile +import org.sudachi.sudachi_emu.model.TaskState +import java.io.BufferedOutputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.charset.StandardCharsets +import java.util.zip.Deflater +import java.util.zip.ZipOutputStream +import kotlin.IllegalStateException + +object FileUtil { + const val PATH_TREE = "tree" + const val DECODE_METHOD = "UTF-8" + const val APPLICATION_OCTET_STREAM = "application/octet-stream" + const val TEXT_PLAIN = "text/plain" + + private val context get() = SudachiApplication.appContext + + /** + * Create a file from directory with filename. + * @param context Application context + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + fun createFile(directory: String?, filename: String): DocumentFile? { + var decodedFilename = filename + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD) + var mimeType = APPLICATION_OCTET_STREAM + if (decodedFilename.endsWith(".txt")) { + mimeType = TEXT_PLAIN + } + val exists = parent.findFile(decodedFilename) + return exists ?: parent.createFile(mimeType, decodedFilename) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Create a directory from directory with filename. + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + fun createDir(directory: String?, directoryName: String?): DocumentFile? { + var decodedDirectoryName = directoryName + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD) + val isExist = parent.findFile(decodedDirectoryName) + return isExist ?: parent.createDirectory(decodedDirectoryName) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Open content uri and return file descriptor to JNI. + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(path: String, openMode: String?): Int { + try { + val uri = Uri.parse(path) + val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!) + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path") + return -1 + } + val fileDescriptor = parcelFileDescriptor.detachFd() + parcelFileDescriptor.close() + return fileDescriptor + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.message) + } + return -1 + } + + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DocumentFile.listFiles + * @param uri Directory uri. + * @return CheapDocument lists. + */ + fun listFiles(uri: Uri): Array { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var c: Cursor? = null + val results: MutableList = ArrayList() + try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = resolver.query(childrenUri, columns, null, null, null) + while (c!!.moveToNext()) { + val documentId = c.getString(0) + val documentName = c.getString(1) + val documentMimeType = c.getString(2) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) + val document = MinimalDocumentFile(documentName, documentMimeType, documentUri) + results.add(document) + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list file error: " + e.message) + } finally { + closeQuietly(c) + } + return results.toTypedArray() + } + + /** + * Check whether given path exists. + * @param path Native content uri path + * @return bool + */ + fun exists(path: String?, suppressLog: Boolean = false): Boolean { + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + c = context.contentResolver.query(mUri, columns, null, null, null) + return c!!.count > 0 + } catch (e: Exception) { + if (!suppressLog) { + Log.info("[FileUtil] Cannot find file from given path, error: " + e.message) + } + } finally { + closeQuietly(c) + } + return false + } + + /** + * Check whether given path is a directory + * @param path content uri path + * @return bool + */ + fun isDirectory(path: String): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var isDirectory = false + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + val mimeType = c.getString(0) + isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list files, error: " + e.message) + } finally { + closeQuietly(c) + } + return isDirectory + } + + /** + * Get file display name from given path + * @param uri content uri + * @return String display name + */ + fun getFilename(uri: Uri): String { + val resolver = SudachiApplication.appContext.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + var filename = "" + var c: Cursor? = null + try { + c = resolver.query(uri, columns, null, null, null) + c!!.moveToNext() + filename = c.getString(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return filename + } + + fun getFilesName(path: String): Array { + val uri = Uri.parse(path) + val files: MutableList = ArrayList() + for (file in listFiles(uri)) { + files.add(file.filename) + } + return files.toTypedArray() + } + + /** + * Get file size from given path. + * @param path content uri path + * @return long file size + */ + @JvmStatic + fun getFileSize(path: String): Long { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_SIZE + ) + var size: Long = 0 + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + size = c.getLong(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return size + } + + /** + * Creates an input stream with a given [Uri] and copies its data to the given path. This will + * overwrite any pre-existing files. + * + * @param sourceUri The [Uri] to copy data from + * @param destinationParentPath Destination directory + * @param destinationFilename Optionally renames the file once copied + */ + fun copyUriToInternalStorage( + sourceUri: Uri, + destinationParentPath: String, + destinationFilename: String = "" + ): File? = + try { + val fileName = + if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename" + val inputStream = context.contentResolver.openInputStream(sourceUri)!! + + val destinationFile = File("$destinationParentPath$fileName") + if (destinationFile.exists()) { + destinationFile.delete() + } + + destinationFile.outputStream().use { fos -> + inputStream.use { it.copyTo(fos) } + } + destinationFile + } catch (e: IOException) { + null + } catch (e: NullPointerException) { + null + } + + /** + * Extracts the given zip file into the given directory. + * @param path String representation of a [Uri] or a typical path delimited by '/' + * @param destDir Location to unzip the contents of [path] into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + */ + @Throws(SecurityException::class) + fun unzipToInternalStorage( + path: String, + destDir: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + var totalEntries = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var tempEntry = zis.nextEntry + while (tempEntry != null) { + tempEntry = zis.nextEntry + totalEntries++ + } + } + + var progress = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + if (progressCallback.invoke(totalEntries, progress)) { + return@use + } + + val newFile = File(destDir, entry.name) + val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile + + if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Zip file attempted path traversal! ${entry.name}") + } + + if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { + throw IOException("Failed to create directory $destinationDirectory") + } + + if (!entry.isDirectory) { + newFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + progress++ + } + } + } + + /** + * Creates a zip file from a directory within internal storage + * @param inputFile File representation of the item that will be zipped + * @param rootDir Directory containing the inputFile + * @param outputStream Stream where the zip file will be output + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @param compression Disables compression if true + */ + fun zipFromInternalStorage( + inputFile: File, + rootDir: String, + outputStream: BufferedOutputStream, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }, + compression: Boolean = true + ): TaskState { + try { + ZipOutputStream(outputStream).use { zos -> + if (!compression) { + zos.setMethod(ZipOutputStream.DEFLATED) + zos.setLevel(Deflater.NO_COMPRESSION) + } + + var count = 0L + val totalFiles = inputFile.walkTopDown().count().toLong() + inputFile.walkTopDown().forEach { file -> + if (progressCallback.invoke(totalFiles, count)) { + return TaskState.Cancelled + } + + if (!file.isDirectory) { + val entryName = + file.absolutePath.removePrefix(rootDir).removePrefix("/") + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + if (file.isFile) { + file.inputStream().use { fis -> fis.copyTo(zos) } + } + count++ + } + } + } + } catch (e: Exception) { + Log.error("[FileUtil] Failed creating zip file - ${e.message}") + return TaskState.Failed + } + return TaskState.Completed + } + + /** + * Helper function that copies the contents of a DocumentFile folder into a [File] + * @param file [File] representation of the folder to copy into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa + */ + fun DocumentFile.copyFilesTo( + file: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + file.mkdirs() + if (!this.isDirectory || !file.isDirectory) { + throw IllegalStateException( + "[FileUtil] Tried to copy a folder into a file or vice versa" + ) + } + + var count = 0L + val totalFiles = this.listFiles().size.toLong() + this.listFiles().forEach { + if (progressCallback.invoke(totalFiles, count)) { + return + } + + val newFile = File(file, it.name!!) + if (it.isDirectory) { + newFile.mkdirs() + DocumentFile.fromTreeUri(SudachiApplication.appContext, it.uri)?.copyFilesTo(newFile) + } else { + val inputStream = + SudachiApplication.appContext.contentResolver.openInputStream(it.uri) + BufferedInputStream(inputStream).use { bos -> + if (!newFile.exists()) { + newFile.createNewFile() + } + newFile.outputStream().use { os -> bos.copyTo(os) } + } + } + count++ + } + } + + fun isRootTreeUri(uri: Uri): Boolean { + val paths = uri.pathSegments + return paths.size == 2 && PATH_TREE == paths[0] + } + + fun closeQuietly(closeable: AutoCloseable?) { + if (closeable != null) { + try { + closeable.close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (ignored: Exception) { + } + } + } + + fun getExtension(uri: Uri): String { + val fileName = getFilename(uri) + return fileName.substring(fileName.lastIndexOf(".") + 1) + .lowercase() + } + + fun isTreeUriValid(uri: Uri): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + return try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + resolver.query(childrenUri, columns, null, null, null) + true + } catch (_: Exception) { + false + } + } + + fun getInputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).inputStream() + } else { + File(path).inputStream() + } + + fun getOutputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).outputStream() + } else { + File(path).outputStream() + } + + @Throws(IOException::class) + fun getStringFromFile(file: File): String = + String(file.readBytes(), StandardCharsets.UTF_8) + + @Throws(IOException::class) + fun getStringFromInputStream(stream: InputStream): String = + String(stream.readBytes(), StandardCharsets.UTF_8) + + fun DocumentFile.inputStream(): InputStream = + SudachiApplication.appContext.contentResolver.openInputStream(uri)!! + + fun DocumentFile.outputStream(): OutputStream = + SudachiApplication.appContext.contentResolver.openOutputStream(uri)!! + + fun Uri.inputStream(): InputStream = + SudachiApplication.appContext.contentResolver.openInputStream(this)!! + + fun Uri.outputStream(): OutputStream = + SudachiApplication.appContext.contentResolver.openOutputStream(this)!! + + fun Uri.asDocumentFile(): DocumentFile? = + DocumentFile.fromSingleUri(SudachiApplication.appContext, this) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt new file mode 100644 index 0000000..9dce357 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GameHelper.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.model.Game +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.model.MinimalDocumentFile + +object GameHelper { + private const val KEY_OLD_GAME_PATH = "game_path" + const val KEY_GAMES = "Games" + + private lateinit var preferences: SharedPreferences + + fun getGames(): List { + val games = mutableListOf() + val context = SudachiApplication.appContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + val gameDirs = mutableListOf() + val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: "" + if (oldGamesDir.isNotEmpty()) { + gameDirs.add(GameDir(oldGamesDir, true)) + preferences.edit().remove(KEY_OLD_GAME_PATH).apply() + } + gameDirs.addAll(NativeConfig.getGameDirs()) + + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + // Reset metadata so we don't use stale information + GameMetadata.resetMetadata() + + // Remove previous filesystem provider information so we can get up to date version info + NativeLibrary.clearFilesystemProvider() + + val badDirs = mutableListOf() + gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> + val gameDirUri = Uri.parse(gameDir.uriString) + val isValid = FileUtil.isTreeUriValid(gameDirUri) + if (isValid) { + addGamesRecursive( + games, + FileUtil.listFiles(gameDirUri), + if (gameDir.deepScan) 3 else 1 + ) + } else { + badDirs.add(index) + } + } + + // Remove all game dirs with insufficient permissions from config + if (badDirs.isNotEmpty()) { + var offset = 0 + badDirs.forEach { + gameDirs.removeAt(it - offset) + offset++ + } + } + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + + // Cache list of games found on disk + val serializedGames = mutableSetOf() + games.forEach { + serializedGames.add(Json.encodeToString(it)) + } + preferences.edit() + .remove(KEY_GAMES) + .putStringSet(KEY_GAMES, serializedGames) + .apply() + + return games.toList() + } + + private fun addGamesRecursive( + games: MutableList, + files: Array, + depth: Int + ) { + if (depth <= 0) { + return + } + + files.forEach { + if (it.isDirectory) { + addGamesRecursive( + games, + FileUtil.listFiles(it.uri), + depth - 1 + ) + } else { + if (Game.extensions.contains(FileUtil.getExtension(it.uri))) { + val game = getGame(it.uri, true) + if (game != null) { + games.add(game) + } + } + } + } + } + + fun getGame(uri: Uri, addedToLibrary: Boolean): Game? { + val filePath = uri.toString() + if (!GameMetadata.getIsValid(filePath)) { + return null + } + + // Needed to update installed content information + NativeLibrary.addFileToFilesystemProvider(filePath) + + var name = GameMetadata.getTitle(filePath) + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = FileUtil.getFilename(uri) + } + var programId = GameMetadata.getProgramId(filePath) + + // If the game's ID field is empty, use the filename without extension. + if (programId.isEmpty()) { + programId = name.substring(0, name.lastIndexOf(".")) + } + + val newGame = Game( + name, + filePath, + programId, + GameMetadata.getDeveloper(filePath), + GameMetadata.getVersion(filePath, false), + GameMetadata.getIsHomebrew(filePath) + ) + + if (addedToLibrary) { + val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) + if (addedTime == 0L) { + preferences.edit() + .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) + .apply() + } + } + + return newGame + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt new file mode 100644 index 0000000..94ef649 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.graphics.SurfaceTexture +import android.net.Uri +import android.os.Build +import android.view.Surface +import java.io.File +import java.io.IOException +import org.sudachi.sudachi_emu.NativeLibrary +import org.sudachi.sudachi_emu.SudachiApplication +import org.sudachi.sudachi_emu.features.settings.model.StringSetting +import java.io.FileNotFoundException +import java.util.zip.ZipException +import java.util.zip.ZipFile + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private var fileRedirectionPath: String? = null + var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/" + + fun initializeDriverParameters() { + try { + // Initialize the file redirection directory. + fileRedirectionPath = SudachiApplication.appContext + .getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = SudachiApplication.appContext + .filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = SudachiApplication.appContext.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + installedCustomDriverData.libraryName, + fileRedirectionPath + ) + } + + fun getDrivers(): MutableList> { + val driverZips = File(driverStoragePath).listFiles() + val drivers: MutableList> = + driverZips + ?.mapNotNull { + val metadata = getMetadataFromZip(it) + metadata.name?.let { _ -> Pair(it.path, metadata) } + } + ?.sortedByDescending { it: Pair -> it.second.name } + ?.distinct() + ?.toMutableList() ?: mutableListOf() + return drivers + } + + fun installDefaultDriver() { + // Removing the installed driver will result in the backend using the default system driver. + File(driverInstallationPath!!).deleteRecursively() + initializeDriverParameters() + } + + fun copyDriverToInternalStorage(driverUri: Uri): Boolean { + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + return true + } + + /** + * Copies driver zip into user data directory so that it can be exported along with + * other user data and also unzipped into the installation directory + */ + fun installCustomDriver(driverUri: Uri): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + + // Unzip the driver. + try { + FileUtil.unzipToInternalStorage( + copiedFile.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Unzips driver into installation directory + */ + fun installCustomDriver(driver: File): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Validate driver + val metadata = getMetadataFromZip(driver) + if (metadata.name == null) { + driver.delete() + return false + } + + // Unzip the driver to the private installation directory + try { + FileUtil.unzipToInternalStorage( + driver.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Takes in a zip file and reads the meta.json file for presentation to the UI + * + * @param driver Zip containing driver and meta.json file + * @return A non-null [GpuDriverMetadata] instance that may have null members + */ + fun getMetadataFromZip(driver: File): GpuDriverMetadata { + try { + ZipFile(driver).use { zf -> + val entries = zf.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { + zf.getInputStream(entry).use { + return GpuDriverMetadata(it, entry.size) + } + } + } + } + } catch (_: ZipException) { + } catch (_: FileNotFoundException) { + } + return GpuDriverMetadata() + } + + external fun supportsCustomDriverLoading(): Boolean + + external fun getSystemDriverInfo( + surface: Surface = Surface(SurfaceTexture(true)), + hookLibPath: String = GpuDriverHelper.hookLibPath!! + ): Array? + + // Parse the custom driver metadata to retrieve the name. + val installedCustomDriverData: GpuDriverMetadata + get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) + + val customDriverSettingData: GpuDriverMetadata + get() = getMetadataFromZip(File(StringSetting.DRIVER_PATH.getString())) + + fun initializeDirectories() { + // Ensure the file redirection directory exists. + val fileRedirectionDir = File(fileRedirectionPath!!) + if (!fileRedirectionDir.exists()) { + fileRedirectionDir.mkdirs() + } + // Ensure the driver installation directory exists. + val driverInstallationDir = File(driverInstallationPath!!) + if (!driverInstallationDir.exists()) { + driverInstallationDir.mkdirs() + } + // Ensure the driver storage directory exists + val driverStorageDirectory = File(driverStoragePath) + if (!driverStorageDirectory.exists()) { + driverStorageDirectory.mkdirs() + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt new file mode 100644 index 0000000..3aa83be --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/InputHandler.kt @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import org.sudachi.sudachi_emu.features.input.NativeInput +import org.sudachi.sudachi_emu.features.input.SudachiInputOverlayDevice +import org.sudachi.sudachi_emu.features.input.SudachiPhysicalDevice + +object InputHandler { + var androidControllers = mapOf() + var registeredControllers = mutableListOf() + + fun dispatchKeyEvent(event: KeyEvent): Boolean { + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + + var controllerData = androidControllers[event.device.controllerNumber] + if (controllerData == null) { + updateControllerData() + controllerData = androidControllers[event.device.controllerNumber] ?: return false + } + + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + return true + } + + fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val controllerData = + androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + } + return true + } + + fun getDevices(): Map { + val gameControllerDeviceIds = mutableMapOf() + val deviceIds = InputDevice.getDeviceIds() + var port = 0 + val inputSettings = NativeConfig.getInputSettings(true) + deviceIds.forEach { deviceId -> + InputDevice.getDevice(deviceId)?.apply { + // Verify that the device has gamepad buttons, control sticks, or both. + if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + ) { + if (!gameControllerDeviceIds.contains(controllerNumber)) { + gameControllerDeviceIds[controllerNumber] = SudachiPhysicalDevice( + this, + port, + inputSettings[port].useSystemVibrator + ) + } + port++ + } + } + } + return gameControllerDeviceIds + } + + fun updateControllerData() { + androidControllers = getDevices() + androidControllers.forEach { + NativeInput.registerController(it.value) + } + + // Register the input overlay on a dedicated port for all player 1 vibrations + NativeInput.registerController(SudachiInputOverlayDevice(androidControllers.isEmpty(), 100)) + registeredControllers.clear() + NativeInput.getInputDevices().forEach { + registeredControllers.add(ParamPackage(it)) + } + registeredControllers.sortBy { it.get("port", 0) } + } + + fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt new file mode 100644 index 0000000..2158a49 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/LifecycleUtils.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** + * Collects this [Flow] with a given [LifecycleOwner]. + * @param scope [LifecycleOwner] that this [Flow] will be collected with. + * @param repeatState When to repeat collection on this [Flow]. + * @param resetState Optional lambda to reset state of an underlying [MutableStateFlow] after + * [stateCollector] has been run. + * @param stateCollector Lambda that receives new state. + */ +inline fun Flow.collect( + scope: LifecycleOwner, + repeatState: Lifecycle.State = Lifecycle.State.CREATED, + crossinline resetState: () -> Unit = {}, + crossinline stateCollector: (state: T) -> Unit +) { + scope.apply { + lifecycleScope.launch { + repeatOnLifecycle(repeatState) { + this@collect.collect { + stateCollector(it) + resetState() + } + } + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt new file mode 100644 index 0000000..884ab83 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/MemoryUtil.kt @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import org.sudachi.sudachi_emu.R +import org.sudachi.sudachi_emu.SudachiApplication +import java.util.Locale +import kotlin.math.ceil + +object MemoryUtil { + private val context get() = SudachiApplication.appContext + + private val Float.hundredths: String + get() = String.format(Locale.ROOT, "%.2f", this) + + // Required total system memory + const val REQUIRED_MEMORY = 8 + + const val Kb: Float = 1024F + const val Mb = Kb * 1024 + const val Gb = Mb * 1024 + const val Tb = Gb * 1024 + const val Pb = Tb * 1024 + const val Eb = Pb * 1024 + + fun bytesToSizeUnit(size: Float, roundUp: Boolean = false): String = + when { + size < Kb -> { + context.getString( + R.string.memory_formatted, + size.hundredths, + context.getString(R.string.memory_byte_shorthand) + ) + } + size < Mb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Kb) else (size / Kb).hundredths, + context.getString(R.string.memory_kilobyte) + ) + } + size < Gb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Mb) else (size / Mb).hundredths, + context.getString(R.string.memory_megabyte) + ) + } + size < Tb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Gb) else (size / Gb).hundredths, + context.getString(R.string.memory_gigabyte) + ) + } + size < Pb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Tb) else (size / Tb).hundredths, + context.getString(R.string.memory_terabyte) + ) + } + size < Eb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Pb) else (size / Pb).hundredths, + context.getString(R.string.memory_petabyte) + ) + } + else -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Eb) else (size / Eb).hundredths, + context.getString(R.string.memory_exabyte) + ) + } + } + + val totalMemory: Float + get() { + val memInfo = ActivityManager.MemoryInfo() + with(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) { + getMemoryInfo(memInfo) + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + memInfo.advertisedMem.toFloat() + } else { + memInfo.totalMem.toFloat() + } + } + + fun isLessThan(minimum: Int, size: Float): Boolean = + when (size) { + Kb -> totalMemory < Mb && totalMemory < minimum + Mb -> totalMemory < Gb && (totalMemory / Mb) < minimum + Gb -> totalMemory < Tb && (totalMemory / Gb) < minimum + Tb -> totalMemory < Pb && (totalMemory / Tb) < minimum + Pb -> totalMemory < Eb && (totalMemory / Pb) < minimum + Eb -> totalMemory / Eb < minimum + else -> totalMemory < Kb && totalMemory < minimum + } + + // Devices are unlikely to have 0.5GB increments of memory so we'll just round up to account for + // the potential error created by memInfo.totalMem + fun getDeviceRAM(): String = bytesToSizeUnit(totalMemory, true) +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt new file mode 100644 index 0000000..a8d3694 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/NativeConfig.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import org.sudachi.sudachi_emu.model.GameDir +import org.sudachi.sudachi_emu.overlay.model.OverlayControlData + +import org.sudachi.sudachi_emu.features.input.model.PlayerInput + +object NativeConfig { + /** + * Loads global config. + */ + @Synchronized + external fun initializeGlobalConfig() + + /** + * Destroys the stored global config object. This does not save the existing config. + */ + @Synchronized + external fun unloadGlobalConfig() + + /** + * Reads values in the global config file and saves them. + */ + @Synchronized + external fun reloadGlobalConfig() + + /** + * Saves global settings values in memory to disk. + */ + @Synchronized + external fun saveGlobalConfig() + + /** + * Creates per-game config for the specified parameters. Must be unloaded once per-game config + * is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets + * will follow the per-game config until the global config is reloaded. + * + * @param programId String representation of the u64 programId + * @param fileName Filename of the game, including its extension + */ + @Synchronized + external fun initializePerGameConfig(programId: String, fileName: String) + + @Synchronized + external fun isPerGameConfigLoaded(): Boolean + + /** + * Saves per-game settings values in memory to disk. + */ + @Synchronized + external fun savePerGameConfig() + + /** + * Destroys the stored per-game config object. This does not save the config. + */ + @Synchronized + external fun unloadPerGameConfig() + + @Synchronized + external fun getBoolean(key: String, needsGlobal: Boolean): Boolean + + @Synchronized + external fun setBoolean(key: String, value: Boolean) + + @Synchronized + external fun getByte(key: String, needsGlobal: Boolean): Byte + + @Synchronized + external fun setByte(key: String, value: Byte) + + @Synchronized + external fun getShort(key: String, needsGlobal: Boolean): Short + + @Synchronized + external fun setShort(key: String, value: Short) + + @Synchronized + external fun getInt(key: String, needsGlobal: Boolean): Int + + @Synchronized + external fun setInt(key: String, value: Int) + + @Synchronized + external fun getFloat(key: String, needsGlobal: Boolean): Float + + @Synchronized + external fun setFloat(key: String, value: Float) + + @Synchronized + external fun getLong(key: String, needsGlobal: Boolean): Long + + @Synchronized + external fun setLong(key: String, value: Long) + + @Synchronized + external fun getString(key: String, needsGlobal: Boolean): String + + @Synchronized + external fun setString(key: String, value: String) + + external fun getIsRuntimeModifiable(key: String): Boolean + + external fun getPairedSettingKey(key: String): String + + external fun getIsSwitchable(key: String): Boolean + + @Synchronized + external fun usingGlobal(key: String): Boolean + + @Synchronized + external fun setGlobal(key: String, global: Boolean) + + external fun getIsSaveable(key: String): Boolean + + external fun getDefaultToString(key: String): String + + /** + * Gets every [GameDir] in AndroidSettings::values.game_dirs + */ + @Synchronized + external fun getGameDirs(): Array + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array) + + /** + * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array + */ + @Synchronized + external fun addGameDir(dir: GameDir) + + /** + * Gets an array of the addons that are disabled for a given game + * + * @param programId String representation of a game's program ID + * @return An array of disabled addons + */ + @Synchronized + external fun getDisabledAddons(programId: String): Array + + /** + * Clears the disabled addons array corresponding to [programId] and replaces them + * with [disabledAddons] + * + * @param programId String representation of a game's program ID + * @param disabledAddons Replacement array of disabled addons + */ + @Synchronized + external fun setDisabledAddons(programId: String, disabledAddons: Array) + + /** + * Gets an array of [OverlayControlData] from settings + * + * @return An array of [OverlayControlData] + */ + @Synchronized + external fun getOverlayControlData(): Array + + /** + * Clears the AndroidSettings::values.overlay_control_data array and replaces its values + * with [overlayControlData] + * + * @param overlayControlData Replacement array of [OverlayControlData] + */ + @Synchronized + external fun setOverlayControlData(overlayControlData: Array) + + @Synchronized + external fun getInputSettings(global: Boolean): Array + + @Synchronized + external fun setInputSettings(value: Array, global: Boolean) + + /** + * Saves control values for a specific player + * Must be used when per game config is loaded + */ + @Synchronized + external fun saveControlPlayerValues() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt new file mode 100644 index 0000000..8d9ccc4 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ParamPackage.kt @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +// Kotlin version of src/common/param_package.h +class ParamPackage(serialized: String = "") { + private val KEY_VALUE_SEPARATOR = ":" + private val PARAM_SEPARATOR = "," + + private val ESCAPE_CHARACTER = "$" + private val KEY_VALUE_SEPARATOR_ESCAPE = "$0" + private val PARAM_SEPARATOR_ESCAPE = "$1" + private val ESCAPE_CHARACTER_ESCAPE = "$2" + + private val EMPTY_PLACEHOLDER = "[empty]" + + val data = mutableMapOf() + + init { + val pairs = serialized.split(PARAM_SEPARATOR) + for (pair in pairs) { + val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList() + if (keyValue.size != 2) { + Log.error("[ParamPackage] Invalid key pair $keyValue") + continue + } + + keyValue.forEachIndexed { i: Int, _: String -> + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR) + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER) + } + + set(keyValue[0], keyValue[1]) + } + } + + constructor(params: List>) : this() { + params.forEach { + data[it.first] = it.second + } + } + + fun serialize(): String { + if (data.isEmpty()) { + return EMPTY_PLACEHOLDER + } + + val result = StringBuilder() + data.forEach { + val keyValue = mutableListOf(it.key, it.value) + keyValue.forEachIndexed { i, _ -> + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE) + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE) + } + result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR") + } + return result.removeSuffix(PARAM_SEPARATOR).toString() + } + + fun get(key: String, defaultValue: String): String = + if (has(key)) { + data[key]!! + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Int): Int = + if (has(key)) { + try { + data[key]!!.toInt() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + private fun Int.toBoolean(): Boolean = + if (this == 1) { + true + } else if (this == 0) { + false + } else { + throw Exception("Tried to convert a value to a boolean that was not 0 or 1!") + } + + fun get(key: String, defaultValue: Boolean): Boolean = + if (has(key)) { + try { + get(key, if (defaultValue) 1 else 0).toBoolean() + } catch (e: Exception) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Float): Float = + if (has(key)) { + try { + data[key]!!.toFloat() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun set(key: String, value: String) { + data[key] = value + } + + fun set(key: String, value: Int) { + data[key] = value.toString() + } + + fun Boolean.toInt(): Int = if (this) 1 else 0 + fun set(key: String, value: Boolean) { + data[key] = value.toInt().toString() + } + + fun set(key: String, value: Float) { + data[key] = value.toString() + } + + fun has(key: String): Boolean = data.containsKey(key) + + fun erase(key: String) = data.remove(key) + + fun clear() = data.clear() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt new file mode 100644 index 0000000..a3daa27 --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/PreferenceUtil.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.SharedPreferences + +object PreferenceUtil { + /** + * Retrieves a shared preference value and then deletes the value in storage. + * @param key Associated key for the value in this preferences instance + * @return Typed value associated with [key]. Null if no such key exists. + */ + inline fun SharedPreferences.migratePreference(key: String): T? { + if (!this.contains(key)) { + return null + } + + val value: Any = when (T::class) { + String::class -> this.getString(key, "")!! + + Boolean::class -> this.getBoolean(key, false) + + Int::class -> this.getInt(key, 0) + + Float::class -> this.getFloat(key, 0f) + + Long::class -> this.getLong(key, 0) + + else -> throw IllegalStateException("Tried to migrate preference with invalid type!") + } + deletePreference(key) + return value as T + } + + fun SharedPreferences.deletePreference(key: String) = this.edit().remove(key).apply() +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt new file mode 100644 index 0000000..a8182ab --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/SerializableHelper.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +object SerializableHelper { + inline fun Bundle.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializable(key, T::class.java) + } else { + getSerializable(key) as? T + } + } + + inline fun Intent.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(key, T::class.java) + } else { + getSerializableExtra(key) as? T + } + } + + inline fun Bundle.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) as? T + } + } + + inline fun Intent.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, T::class.java) + } else { + getParcelableExtra(key) as? T + } + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt new file mode 100644 index 0000000..4509cdc --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/utils/ViewUtils.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.utils + +import android.text.TextUtils +import android.view.View +import android.view.ViewGroup +import android.widget.TextView + +object ViewUtils { + fun showView(view: View, length: Long = 300) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = length + alpha(1f) + }.start() + } + + fun hideView(view: View, length: Long = 300) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = length + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + }.start() + } + + fun View.updateMargins( + left: Int = -1, + top: Int = -1, + right: Int = -1, + bottom: Int = -1 + ) { + val layoutParams = this.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.apply { + if (left != -1) { + leftMargin = left + } + if (top != -1) { + topMargin = top + } + if (right != -1) { + rightMargin = right + } + if (bottom != -1) { + bottomMargin = bottom + } + } + this.layoutParams = layoutParams + } + + /** + * Shows or hides a view. + * @param visible Whether a view will be made View.VISIBLE or View.INVISIBLE/GONE. + * @param gone Optional parameter for hiding a view. Uses View.GONE if true and View.INVISIBLE otherwise. + */ + fun View.setVisible(visible: Boolean, gone: Boolean = true) { + visibility = if (visible) { + View.VISIBLE + } else { + if (gone) { + View.GONE + } else { + View.INVISIBLE + } + } + } + + /** + * Starts a marquee on some text. + * @param delay Optional parameter for changing the start delay. 3 seconds of delay by default. + */ + fun TextView.marquee(delay: Long = 3000) { + ellipsize = null + marqueeRepeatLimit = -1 + isSingleLine = true + postDelayed({ + ellipsize = TextUtils.TruncateAt.MARQUEE + isSelected = true + }, delay) + } +} diff --git a/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt new file mode 100644 index 0000000..1a2874c --- /dev/null +++ b/src/android/sudachi/src/main/java/org/sudachi/sudachi_emu/views/FixedRatioSurfaceView.kt @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.sudachi.sudachi_emu.views + +import android.content.Context +import android.util.AttributeSet +import android.util.Rational +import android.view.SurfaceView + +class FixedRatioSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SurfaceView(context, attrs, defStyleAttr) { + private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch + + /** + * Sets the desired aspect ratio for this view + * @param ratio the ratio to force the view to, or null to stretch to fit + */ + fun setAspectRatio(ratio: Rational?) { + aspectRatio = ratio?.toFloat() ?: 0f + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val displayWidth: Float = MeasureSpec.getSize(widthMeasureSpec).toFloat() + val displayHeight: Float = MeasureSpec.getSize(heightMeasureSpec).toFloat() + if (aspectRatio != 0f) { + val displayAspect = displayWidth / displayHeight + if (displayAspect < aspectRatio) { + // Max out width + val halfHeight = displayHeight / 2 + val surfaceHeight = displayWidth / aspectRatio + val newTop: Float = halfHeight - (surfaceHeight / 2) + val newBottom: Float = halfHeight + (surfaceHeight / 2) + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec( + newBottom.toInt() - newTop.toInt(), + MeasureSpec.EXACTLY + ) + ) + return + } else { + // Max out height + val halfWidth = displayWidth / 2 + val surfaceWidth = displayHeight * aspectRatio + val newLeft: Float = halfWidth - (surfaceWidth / 2) + val newRight: Float = halfWidth + (surfaceWidth / 2) + super.onMeasure( + MeasureSpec.makeMeasureSpec( + newRight.toInt() - newLeft.toInt(), + MeasureSpec.EXACTLY + ), + heightMeasureSpec + ) + return + } + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } +} diff --git a/src/android/sudachi/src/main/jni/CMakeLists.txt b/src/android/sudachi/src/main/jni/CMakeLists.txt new file mode 100644 index 0000000..b0a750c --- /dev/null +++ b/src/android/sudachi/src/main/jni/CMakeLists.txt @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2023 sudachi Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +add_library(sudachi-android SHARED + emu_window/emu_window.cpp + emu_window/emu_window.h + native.cpp + native.h + native_config.cpp + android_settings.cpp + game_metadata.cpp + native_log.cpp + android_config.cpp + android_config.h + native_input.cpp +) + +set_property(TARGET sudachi-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR}) + +target_link_libraries(sudachi-android PRIVATE audio_core common core input_common frontend_common Vulkan::Headers) +target_link_libraries(sudachi-android PRIVATE android camera2ndk EGL glad jnigraphics log) +if (ARCHITECTURE_arm64) + target_link_libraries(sudachi-android PRIVATE adrenotools) +endif() + +set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} sudachi-android) diff --git a/src/android/sudachi/src/main/jni/android_common/android_common.cpp b/src/android/sudachi/src/main/jni/android_common/android_common.cpp new file mode 100644 index 0000000..b06d73c --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_common/android_common.cpp @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "jni/android_common/android_common.h" + +#include +#include + +#include + +#include "common/string_util.h" +#include "jni/id_cache.h" + +std::string GetJString(JNIEnv* env, jstring jstr) { + if (!jstr) { + return {}; + } + + const jchar* jchars = env->GetStringChars(jstr, nullptr); + const jsize length = env->GetStringLength(jstr); + const std::u16string_view string_view(reinterpret_cast(jchars), length); + const std::string converted_string = Common::UTF16ToUTF8(string_view); + env->ReleaseStringChars(jstr, jchars); + + return converted_string; +} + +jstring ToJString(JNIEnv* env, std::string_view str) { + const std::u16string converted_string = Common::UTF8ToUTF16(str); + return env->NewString(reinterpret_cast(converted_string.data()), + static_cast(converted_string.size())); +} + +jstring ToJString(JNIEnv* env, std::u16string_view str) { + return ToJString(env, Common::UTF16ToUTF8(str)); +} + +double GetJDouble(JNIEnv* env, jobject jdouble) { + return env->GetDoubleField(jdouble, IDCache::GetDoubleValueField()); +} + +jobject ToJDouble(JNIEnv* env, double value) { + return env->NewObject(IDCache::GetDoubleClass(), IDCache::GetDoubleConstructor(), value); +} + +s32 GetJInteger(JNIEnv* env, jobject jinteger) { + return env->GetIntField(jinteger, IDCache::GetIntegerValueField()); +} + +jobject ToJInteger(JNIEnv* env, s32 value) { + return env->NewObject(IDCache::GetIntegerClass(), IDCache::GetIntegerConstructor(), value); +} + +bool GetJBoolean(JNIEnv* env, jobject jboolean) { + return env->GetBooleanField(jboolean, IDCache::GetBooleanValueField()); +} + +jobject ToJBoolean(JNIEnv* env, bool value) { + return env->NewObject(IDCache::GetBooleanClass(), IDCache::GetBooleanConstructor(), value); +} diff --git a/src/android/sudachi/src/main/jni/android_common/android_common.h b/src/android/sudachi/src/main/jni/android_common/android_common.h new file mode 100644 index 0000000..5fd1c05 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_common/android_common.h @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include +#include "common/common_types.h" + +std::string GetJString(JNIEnv* env, jstring jstr); +jstring ToJString(JNIEnv* env, std::string_view str); +jstring ToJString(JNIEnv* env, std::u16string_view str); + +double GetJDouble(JNIEnv* env, jobject jdouble); +jobject ToJDouble(JNIEnv* env, double value); + +s32 GetJInteger(JNIEnv* env, jobject jinteger); +jobject ToJInteger(JNIEnv* env, s32 value); + +bool GetJBoolean(JNIEnv* env, jobject jboolean); +jobject ToJBoolean(JNIEnv* env, bool value); diff --git a/src/android/sudachi/src/main/jni/android_config.cpp b/src/android/sudachi/src/main/jni/android_config.cpp new file mode 100644 index 0000000..ef8a850 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_config.cpp @@ -0,0 +1,337 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include "android_config.h" +#include "android_settings.h" +#include "common/settings_setting.h" + +AndroidConfig::AndroidConfig(const std::string& config_name, ConfigType config_type) + : Config(config_type) { + Initialize(config_name); + if (config_type != ConfigType::InputProfile) { + ReadAndroidValues(); + SaveAndroidValues(); + } +} + +void AndroidConfig::ReloadAllValues() { + Reload(); + ReadAndroidValues(); + SaveAndroidValues(); +} + +void AndroidConfig::SaveAllValues() { + SaveValues(); + SaveAndroidValues(); +} + +void AndroidConfig::ReadAndroidValues() { + if (global) { + ReadAndroidUIValues(); + ReadUIValues(); + ReadOverlayValues(); + } + ReadDriverValues(); + ReadAndroidControlValues(); +} + +void AndroidConfig::ReadAndroidUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Android)); + + ReadCategory(Settings::Category::Android); + + EndGroup(); +} + +void AndroidConfig::ReadUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + ReadPathValues(); + + EndGroup(); +} + +void AndroidConfig::ReadPathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + AndroidSettings::values.game_dirs.clear(); + const int gamedirs_size = BeginArray(std::string("gamedirs")); + for (int i = 0; i < gamedirs_size; ++i) { + SetArrayIndex(i); + AndroidSettings::GameDir game_dir; + game_dir.path = ReadStringSetting(std::string("path")); + game_dir.deep_scan = + ReadBooleanSetting(std::string("deep_scan"), std::make_optional(false)); + AndroidSettings::values.game_dirs.push_back(game_dir); + } + EndArray(); + + EndGroup(); +} + +void AndroidConfig::ReadDriverValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::GpuDriver)); + + ReadCategory(Settings::Category::GpuDriver); + + EndGroup(); +} + +void AndroidConfig::ReadOverlayValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Overlay)); + + ReadCategory(Settings::Category::Overlay); + + AndroidSettings::values.overlay_control_data.clear(); + const int control_data_size = BeginArray("control_data"); + for (int i = 0; i < control_data_size; ++i) { + SetArrayIndex(i); + AndroidSettings::OverlayControlData control_data; + control_data.id = ReadStringSetting(std::string("id")); + control_data.enabled = ReadBooleanSetting(std::string("enabled")); + control_data.landscape_position.first = + ReadDoubleSetting(std::string("landscape\\x_position")); + control_data.landscape_position.second = + ReadDoubleSetting(std::string("landscape\\y_position")); + control_data.portrait_position.first = + ReadDoubleSetting(std::string("portrait\\x_position")); + control_data.portrait_position.second = + ReadDoubleSetting(std::string("portrait\\y_position")); + control_data.foldable_position.first = + ReadDoubleSetting(std::string("foldable\\x_position")); + control_data.foldable_position.second = + ReadDoubleSetting(std::string("foldable\\y_position")); + AndroidSettings::values.overlay_control_data.push_back(control_data); + } + EndArray(); + + EndGroup(); +} + +void AndroidConfig::ReadAndroidPlayerValues(std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix.append("player_").append(ToString(player_index)).append("_"); + } + + auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig()) { + const auto profile_name = + ReadStringSetting(std::string(player_prefix).append("profile_name")); + if (profile_name.empty()) { + // Use the global input config + player = Settings::values.players.GetValue(true)[player_index]; + player.profile_name = ""; + return; + } + } + + // Android doesn't have default options for controllers. We have the input overlay for that. + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + const std::string default_param; + auto& player_buttons = player.buttons[i]; + + player_buttons = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeButton::mapping[i]), default_param); + if (player_buttons.empty()) { + player_buttons = default_param; + } + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + const std::string default_param; + auto& player_analogs = player.analogs[i]; + + player_analogs = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), default_param); + if (player_analogs.empty()) { + player_analogs = default_param; + } + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + const std::string default_param; + auto& player_motions = player.motions[i]; + + player_motions = ReadStringSetting( + std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), default_param); + if (player_motions.empty()) { + player_motions = default_param; + } + } + player.use_system_vibrator = ReadBooleanSetting( + std::string(player_prefix).append("use_system_vibrator"), player_index == 0); +} + +void AndroidConfig::ReadAndroidControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + ReadAndroidPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + // ReadDebugControlValues(); + // ReadHidbusValues(); + + EndGroup(); +} + +void AndroidConfig::SaveAndroidValues() { + if (global) { + SaveAndroidUIValues(); + SaveUIValues(); + SaveOverlayValues(); + } + SaveDriverValues(); + SaveAndroidControlValues(); + + WriteToIni(); +} + +void AndroidConfig::SaveAndroidUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Android)); + + WriteCategory(Settings::Category::Android); + + EndGroup(); +} + +void AndroidConfig::SaveUIValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Ui)); + + SavePathValues(); + + EndGroup(); +} + +void AndroidConfig::SavePathValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Paths)); + + BeginArray(std::string("gamedirs")); + for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) { + SetArrayIndex(i); + const auto& game_dir = AndroidSettings::values.game_dirs[i]; + WriteStringSetting(std::string("path"), game_dir.path); + WriteBooleanSetting(std::string("deep_scan"), game_dir.deep_scan, + std::make_optional(false)); + } + EndArray(); + + EndGroup(); +} + +void AndroidConfig::SaveDriverValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::GpuDriver)); + + WriteCategory(Settings::Category::GpuDriver); + + EndGroup(); +} + +void AndroidConfig::SaveOverlayValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Overlay)); + + WriteCategory(Settings::Category::Overlay); + + BeginArray("control_data"); + for (size_t i = 0; i < AndroidSettings::values.overlay_control_data.size(); ++i) { + SetArrayIndex(i); + const auto& control_data = AndroidSettings::values.overlay_control_data[i]; + WriteStringSetting(std::string("id"), control_data.id); + WriteBooleanSetting(std::string("enabled"), control_data.enabled); + WriteDoubleSetting(std::string("landscape\\x_position"), + control_data.landscape_position.first); + WriteDoubleSetting(std::string("landscape\\y_position"), + control_data.landscape_position.second); + WriteDoubleSetting(std::string("portrait\\x_position"), + control_data.portrait_position.first); + WriteDoubleSetting(std::string("portrait\\y_position"), + control_data.portrait_position.second); + WriteDoubleSetting(std::string("foldable\\x_position"), + control_data.foldable_position.first); + WriteDoubleSetting(std::string("foldable\\y_position"), + control_data.foldable_position.second); + } + EndArray(); + + EndGroup(); +} + +void AndroidConfig::SaveAndroidPlayerValues(std::size_t player_index) { + std::string player_prefix; + if (type != ConfigType::InputProfile) { + player_prefix = std::string("player_").append(ToString(player_index)).append("_"); + } + + const auto& player = Settings::values.players.GetValue()[player_index]; + if (IsCustomConfig() && player.profile_name.empty()) { + // No custom profile selected + return; + } + + const std::string default_param; + for (int i = 0; i < Settings::NativeButton::NumButtons; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeButton::mapping[i]), + player.buttons[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeAnalog::NumAnalogs; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeAnalog::mapping[i]), + player.analogs[i], std::make_optional(default_param)); + } + for (int i = 0; i < Settings::NativeMotion::NumMotions; ++i) { + WriteStringSetting(std::string(player_prefix).append(Settings::NativeMotion::mapping[i]), + player.motions[i], std::make_optional(default_param)); + } + WriteBooleanSetting(std::string(player_prefix).append("use_system_vibrator"), + player.use_system_vibrator, std::make_optional(player_index == 0)); +} + +void AndroidConfig::SaveAndroidControlValues() { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + Settings::values.players.SetGlobal(!IsCustomConfig()); + for (std::size_t p = 0; p < Settings::values.players.GetValue().size(); ++p) { + SaveAndroidPlayerValues(p); + } + if (IsCustomConfig()) { + EndGroup(); + return; + } + // SaveDebugControlValues(); + // SaveHidbusValues(); + + EndGroup(); +} + +std::vector& AndroidConfig::FindRelevantList(Settings::Category category) { + auto& map = Settings::values.linkage.by_category; + if (map.contains(category)) { + return Settings::values.linkage.by_category[category]; + } + return AndroidSettings::values.linkage.by_category[category]; +} + +void AndroidConfig::ReadAndroidControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + ReadPlayerValues(player_index); + ReadAndroidPlayerValues(player_index); + + EndGroup(); +} + +void AndroidConfig::SaveAndroidControlPlayerValues(std::size_t player_index) { + BeginGroup(Settings::TranslateCategory(Settings::Category::Controls)); + + LOG_DEBUG(Config, "Saving players control configuration values"); + SavePlayerValues(player_index); + SaveAndroidPlayerValues(player_index); + + EndGroup(); + + WriteToIni(); +} diff --git a/src/android/sudachi/src/main/jni/android_config.h b/src/android/sudachi/src/main/jni/android_config.h new file mode 100644 index 0000000..246c4c4 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_config.h @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "frontend_common/config.h" + +class AndroidConfig final : public Config { +public: + explicit AndroidConfig(const std::string& config_name = "config", + ConfigType config_type = ConfigType::GlobalConfig); + + void ReloadAllValues() override; + void SaveAllValues() override; + + void ReadAndroidControlPlayerValues(std::size_t player_index); + void SaveAndroidControlPlayerValues(std::size_t player_index); + +protected: + void ReadAndroidPlayerValues(std::size_t player_index); + void ReadAndroidControlValues(); + void ReadAndroidValues(); + void ReadAndroidUIValues(); + void ReadDriverValues(); + void ReadOverlayValues(); + void ReadHidbusValues() override {} + void ReadDebugControlValues() override {} + void ReadPathValues() override; + void ReadShortcutValues() override {} + void ReadUIValues() override; + void ReadUIGamelistValues() override {} + void ReadUILayoutValues() override {} + void ReadMultiplayerValues() override {} + + void SaveAndroidPlayerValues(std::size_t player_index); + void SaveAndroidControlValues(); + void SaveAndroidValues(); + void SaveAndroidUIValues(); + void SaveDriverValues(); + void SaveOverlayValues(); + void SaveHidbusValues() override {} + void SaveDebugControlValues() override {} + void SavePathValues() override; + void SaveShortcutValues() override {} + void SaveUIValues() override; + void SaveUIGamelistValues() override {} + void SaveUILayoutValues() override {} + void SaveMultiplayerValues() override {} + + std::vector& FindRelevantList(Settings::Category category) override; +}; diff --git a/src/android/sudachi/src/main/jni/android_settings.cpp b/src/android/sudachi/src/main/jni/android_settings.cpp new file mode 100644 index 0000000..ee99827 --- /dev/null +++ b/src/android/sudachi/src/main/jni/android_settings.cpp @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "android_settings.h" + +namespace AndroidSettings { + +Values values; + +} // namespace AndroidSettings diff --git a/src/android/sudachi/src/main/jni/config.cpp b/src/android/sudachi/src/main/jni/config.cpp new file mode 100644 index 0000000..74827a0 --- /dev/null +++ b/src/android/sudachi/src/main/jni/config.cpp @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include + +#include +#include "common/fs/file.h" +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/settings_enums.h" +#include "core/hle/service/acc/profile_manager.h" +#include "input_common/main.h" +#include "jni/config.h" +#include "jni/default_ini.h" +#include "uisettings.h" + +namespace FS = Common::FS; + +Config::Config(const std::string& config_name, ConfigType config_type) + : type(config_type), global{config_type == ConfigType::GlobalConfig} { + Initialize(config_name); +} + +Config::~Config() = default; + +bool Config::LoadINI(const std::string& default_contents, bool retry) { + void(FS::CreateParentDir(config_loc)); + config = std::make_unique(FS::PathToUTF8String(config_loc)); + const auto config_loc_str = FS::PathToUTF8String(config_loc); + if (config->ParseError() < 0) { + if (retry) { + LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...", + config_loc_str); + + void(FS::CreateParentDir(config_loc)); + void(FS::WriteStringToFile(config_loc, FS::FileType::TextFile, default_contents)); + + config = std::make_unique(config_loc_str); + + return LoadINI(default_contents, false); + } + LOG_ERROR(Config, "Failed."); + return false; + } + LOG_INFO(Config, "Successfully loaded {}", config_loc_str); + return true; +} + +template <> +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + std::string setting_value = config->Get(group, setting.GetLabel(), setting.GetDefault()); + if (setting_value.empty()) { + setting_value = setting.GetDefault(); + } + setting = std::move(setting_value); +} + +template <> +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + setting = config->GetBoolean(group, setting.GetLabel(), setting.GetDefault()); +} + +template +void Config::ReadSetting(const std::string& group, Settings::Setting& setting) { + setting = static_cast( + config->GetInteger(group, setting.GetLabel(), static_cast(setting.GetDefault()))); +} + +void Config::ReadValues() { + ReadSetting("ControlsGeneral", Settings::values.mouse_enabled); + ReadSetting("ControlsGeneral", Settings::values.touch_device); + ReadSetting("ControlsGeneral", Settings::values.keyboard_enabled); + ReadSetting("ControlsGeneral", Settings::values.debug_pad_enabled); + ReadSetting("ControlsGeneral", Settings::values.vibration_enabled); + ReadSetting("ControlsGeneral", Settings::values.enable_accurate_vibrations); + ReadSetting("ControlsGeneral", Settings::values.motion_enabled); + Settings::values.touchscreen.enabled = + config->GetBoolean("ControlsGeneral", "touch_enabled", true); + Settings::values.touchscreen.rotation_angle = + config->GetInteger("ControlsGeneral", "touch_angle", 0); + Settings::values.touchscreen.diameter_x = + config->GetInteger("ControlsGeneral", "touch_diameter_x", 15); + Settings::values.touchscreen.diameter_y = + config->GetInteger("ControlsGeneral", "touch_diameter_y", 15); + + int num_touch_from_button_maps = + config->GetInteger("ControlsGeneral", "touch_from_button_map", 0); + if (num_touch_from_button_maps > 0) { + for (int i = 0; i < num_touch_from_button_maps; ++i) { + Settings::TouchFromButtonMap map; + map.name = config->Get("ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_name"), + "default"); + const int num_touch_maps = config->GetInteger( + "ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + std::string("_count"), + 0); + map.buttons.reserve(num_touch_maps); + + for (int j = 0; j < num_touch_maps; ++j) { + std::string touch_mapping = + config->Get("ControlsGeneral", + std::string("touch_from_button_maps_") + std::to_string(i) + + std::string("_bind_") + std::to_string(j), + ""); + map.buttons.emplace_back(std::move(touch_mapping)); + } + + Settings::values.touch_from_button_maps.emplace_back(std::move(map)); + } + } else { + Settings::values.touch_from_button_maps.emplace_back( + Settings::TouchFromButtonMap{"default", {}}); + num_touch_from_button_maps = 1; + } + Settings::values.touch_from_button_map_index = std::clamp( + Settings::values.touch_from_button_map_index.GetValue(), 0, num_touch_from_button_maps - 1); + + ReadSetting("ControlsGeneral", Settings::values.udp_input_servers); + + // Data Storage + ReadSetting("Data Storage", Settings::values.use_virtual_sd); + FS::SetSudachiPath(FS::SudachiPath::NANDDir, + config->Get("Data Storage", "nand_directory", + FS::GetSudachiPathString(FS::SudachiPath::NANDDir))); + FS::SetSudachiPath(FS::SudachiPath::SDMCDir, + config->Get("Data Storage", "sdmc_directory", + FS::GetSudachiPathString(FS::SudachiPath::SDMCDir))); + FS::SetSudachiPath(FS::SudachiPath::LoadDir, + config->Get("Data Storage", "load_directory", + FS::GetSudachiPathString(FS::SudachiPath::LoadDir))); + FS::SetSudachiPath(FS::SudachiPath::DumpDir, + config->Get("Data Storage", "dump_directory", + FS::GetSudachiPathString(FS::SudachiPath::DumpDir))); + ReadSetting("Data Storage", Settings::values.gamecard_inserted); + ReadSetting("Data Storage", Settings::values.gamecard_current_game); + ReadSetting("Data Storage", Settings::values.gamecard_path); + + // System + ReadSetting("System", Settings::values.current_user); + Settings::values.current_user = std::clamp(Settings::values.current_user.GetValue(), 0, + Service::Account::MAX_USERS - 1); + + // Disable docked mode by default on Android + Settings::values.use_docked_mode.SetValue(config->GetBoolean("System", "use_docked_mode", false) + ? Settings::ConsoleMode::Docked + : Settings::ConsoleMode::Handheld); + + const auto rng_seed_enabled = config->GetBoolean("System", "rng_seed_enabled", false); + if (rng_seed_enabled) { + Settings::values.rng_seed.SetValue(config->GetInteger("System", "rng_seed", 0)); + } else { + Settings::values.rng_seed.SetValue(0); + } + Settings::values.rng_seed_enabled.SetValue(rng_seed_enabled); + + const auto custom_rtc_enabled = config->GetBoolean("System", "custom_rtc_enabled", false); + if (custom_rtc_enabled) { + Settings::values.custom_rtc = config->GetInteger("System", "custom_rtc", 0); + } else { + Settings::values.custom_rtc = 0; + } + Settings::values.custom_rtc_enabled = custom_rtc_enabled; + + ReadSetting("System", Settings::values.language_index); + ReadSetting("System", Settings::values.region_index); + ReadSetting("System", Settings::values.time_zone_index); + ReadSetting("System", Settings::values.sound_index); + + // Core + ReadSetting("Core", Settings::values.use_multi_core); + ReadSetting("Core", Settings::values.memory_layout_mode); + + // Cpu + ReadSetting("Cpu", Settings::values.cpu_accuracy); + ReadSetting("Cpu", Settings::values.cpu_debug_mode); + ReadSetting("Cpu", Settings::values.cpuopt_page_tables); + ReadSetting("Cpu", Settings::values.cpuopt_block_linking); + ReadSetting("Cpu", Settings::values.cpuopt_return_stack_buffer); + ReadSetting("Cpu", Settings::values.cpuopt_fast_dispatcher); + ReadSetting("Cpu", Settings::values.cpuopt_context_elimination); + ReadSetting("Cpu", Settings::values.cpuopt_const_prop); + ReadSetting("Cpu", Settings::values.cpuopt_misc_ir); + ReadSetting("Cpu", Settings::values.cpuopt_reduce_misalign_checks); + ReadSetting("Cpu", Settings::values.cpuopt_fastmem); + ReadSetting("Cpu", Settings::values.cpuopt_fastmem_exclusives); + ReadSetting("Cpu", Settings::values.cpuopt_recompile_exclusives); + ReadSetting("Cpu", Settings::values.cpuopt_ignore_memory_aborts); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_unfuse_fma); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_reduce_fp_error); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_standard_fpcr); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_inaccurate_nan); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_fastmem_check); + ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_global_monitor); + + // Renderer + ReadSetting("Renderer", Settings::values.renderer_backend); + ReadSetting("Renderer", Settings::values.renderer_debug); + ReadSetting("Renderer", Settings::values.renderer_shader_feedback); + ReadSetting("Renderer", Settings::values.enable_nsight_aftermath); + ReadSetting("Renderer", Settings::values.disable_shader_loop_safety_checks); + ReadSetting("Renderer", Settings::values.vulkan_device); + + ReadSetting("Renderer", Settings::values.resolution_setup); + ReadSetting("Renderer", Settings::values.scaling_filter); + ReadSetting("Renderer", Settings::values.fsr_sharpening_slider); + ReadSetting("Renderer", Settings::values.anti_aliasing); + ReadSetting("Renderer", Settings::values.fullscreen_mode); + ReadSetting("Renderer", Settings::values.aspect_ratio); + ReadSetting("Renderer", Settings::values.max_anisotropy); + ReadSetting("Renderer", Settings::values.use_speed_limit); + ReadSetting("Renderer", Settings::values.speed_limit); + ReadSetting("Renderer", Settings::values.use_disk_shader_cache); + ReadSetting("Renderer", Settings::values.use_asynchronous_gpu_emulation); + ReadSetting("Renderer", Settings::values.vsync_mode); + ReadSetting("Renderer", Settings::values.shader_backend); + ReadSetting("Renderer", Settings::values.use_asynchronous_shaders); + ReadSetting("Renderer", Settings::values.nvdec_emulation); + ReadSetting("Renderer", Settings::values.use_fast_gpu_time); + ReadSetting("Renderer", Settings::values.use_vulkan_driver_pipeline_cache); + + ReadSetting("Renderer", Settings::values.bg_red); + ReadSetting("Renderer", Settings::values.bg_green); + ReadSetting("Renderer", Settings::values.bg_blue); + + // Use GPU accuracy normal by default on Android + Settings::values.gpu_accuracy = static_cast(config->GetInteger( + "Renderer", "gpu_accuracy", static_cast(Settings::GpuAccuracy::Normal))); + + // Use GPU default anisotropic filtering on Android + Settings::values.max_anisotropy = + static_cast(config->GetInteger("Renderer", "max_anisotropy", 1)); + + // Disable ASTC compute by default on Android + Settings::values.accelerate_astc.SetValue( + config->GetBoolean("Renderer", "accelerate_astc", false) ? Settings::AstcDecodeMode::Gpu + : Settings::AstcDecodeMode::Cpu); + + // Enable asynchronous presentation by default on Android + Settings::values.async_presentation = + config->GetBoolean("Renderer", "async_presentation", true); + + // Disable force_max_clock by default on Android + Settings::values.renderer_force_max_clock = + config->GetBoolean("Renderer", "force_max_clock", false); + + // Disable use_reactive_flushing by default on Android + Settings::values.use_reactive_flushing = + config->GetBoolean("Renderer", "use_reactive_flushing", false); + + // Audio + ReadSetting("Audio", Settings::values.sink_id); + ReadSetting("Audio", Settings::values.audio_output_device_id); + ReadSetting("Audio", Settings::values.volume); + + // Miscellaneous + // log_filter has a different default here than from common + Settings::values.log_filter = "*:Info"; + ReadSetting("Miscellaneous", Settings::values.use_dev_keys); + + // Debugging + Settings::values.record_frame_times = + config->GetBoolean("Debugging", "record_frame_times", false); + ReadSetting("Debugging", Settings::values.dump_exefs); + ReadSetting("Debugging", Settings::values.dump_nso); + ReadSetting("Debugging", Settings::values.enable_fs_access_log); + ReadSetting("Debugging", Settings::values.reporting_services); + ReadSetting("Debugging", Settings::values.quest_flag); + ReadSetting("Debugging", Settings::values.use_debug_asserts); + ReadSetting("Debugging", Settings::values.use_auto_stub); + ReadSetting("Debugging", Settings::values.disable_macro_jit); + ReadSetting("Debugging", Settings::values.disable_macro_hle); + ReadSetting("Debugging", Settings::values.use_gdbstub); + ReadSetting("Debugging", Settings::values.gdbstub_port); + + const auto title_list = config->Get("AddOns", "title_ids", ""); + std::stringstream ss(title_list); + std::string line; + while (std::getline(ss, line, '|')) { + const auto title_id = std::strtoul(line.c_str(), nullptr, 16); + const auto disabled_list = config->Get("AddOns", "disabled_" + line, ""); + + std::stringstream inner_ss(disabled_list); + std::string inner_line; + std::vector out; + while (std::getline(inner_ss, inner_line, '|')) { + out.push_back(inner_line); + } + + Settings::values.disabled_addons.insert_or_assign(title_id, out); + } + + // Web Service + ReadSetting("WebService", Settings::values.enable_telemetry); + ReadSetting("WebService", Settings::values.web_api_url); + ReadSetting("WebService", Settings::values.sudachi_username); + ReadSetting("WebService", Settings::values.sudachi_token); + + // Network + ReadSetting("Network", Settings::values.network_interface); + + // Android + ReadSetting("Android", AndroidSettings::values.picture_in_picture); + ReadSetting("Android", AndroidSettings::values.screen_layout); +} + +void Config::Initialize(const std::string& config_name) { + const auto fs_config_loc = FS::GetSudachiPath(FS::SudachiPath::ConfigDir); + const auto config_file = fmt::format("{}.ini", config_name); + + switch (type) { + case ConfigType::GlobalConfig: + config_loc = FS::PathToUTF8String(fs_config_loc / config_file); + break; + case ConfigType::PerGameConfig: + config_loc = FS::PathToUTF8String(fs_config_loc / "custom" / FS::ToU8String(config_file)); + break; + case ConfigType::InputProfile: + config_loc = FS::PathToUTF8String(fs_config_loc / "input" / config_file); + LoadINI(DefaultINI::android_config_file); + return; + } + LoadINI(DefaultINI::android_config_file); + ReadValues(); +} diff --git a/src/android/sudachi/src/main/jni/config.h b/src/android/sudachi/src/main/jni/config.h new file mode 100644 index 0000000..6c13cd0 --- /dev/null +++ b/src/android/sudachi/src/main/jni/config.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "common/settings.h" + +class INIReader; + +class Config { + bool LoadINI(const std::string& default_contents = "", bool retry = true); + +public: + enum class ConfigType { + GlobalConfig, + PerGameConfig, + InputProfile, + }; + + explicit Config(const std::string& config_name = "config", + ConfigType config_type = ConfigType::GlobalConfig); + ~Config(); + + void Initialize(const std::string& config_name); + +private: + /** + * Applies a value read from the config to a Setting. + * + * @param group The name of the INI group + * @param setting The sudachi setting to modify + */ + template + void ReadSetting(const std::string& group, Settings::Setting& setting); + + void ReadValues(); + + const ConfigType type; + std::unique_ptr config; + std::string config_loc; + const bool global; +}; diff --git a/src/android/sudachi/src/main/jni/default_ini.h b/src/android/sudachi/src/main/jni/default_ini.h new file mode 100644 index 0000000..23f6436 --- /dev/null +++ b/src/android/sudachi/src/main/jni/default_ini.h @@ -0,0 +1,511 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +namespace DefaultINI { + +const char* android_config_file = R"( + +[ControlsP0] +# The input devices and parameters for each Switch native input +# The config section determines the player number where the config will be applied on. For example "ControlsP0", "ControlsP1", ... +# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..." +# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values + +# Indicates if this player should be connected at boot +connected= + +# for button input, the following devices are available: +# - "keyboard" (default) for keyboard input. Required parameters: +# - "code": the code of the key to bind +# - "sdl" for joystick input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "button"(optional): the index of the button to bind +# - "hat"(optional): the index of the hat to bind as direction buttons +# - "axis"(optional): the index of the axis to bind +# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right" +# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is +# triggered if the axis value crosses +# - "direction"(only used for axis): "+" means the button is triggered when the axis value +# is greater than the threshold; "-" means the button is triggered when the axis value +# is smaller than the threshold +button_a= +button_b= +button_x= +button_y= +button_lstick= +button_rstick= +button_l= +button_r= +button_zl= +button_zr= +button_plus= +button_minus= +button_dleft= +button_dup= +button_dright= +button_ddown= +button_lstick_left= +button_lstick_up= +button_lstick_right= +button_lstick_down= +button_sl= +button_sr= +button_home= +button_screenshot= + +# for analog input, the following devices are available: +# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters: +# - "up", "down", "left", "right": sub-devices for each direction. +# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00" +# - "modifier": sub-devices as a modifier. +# - "modifier_scale": a float number representing the applied modifier scale to the analog input. +# Must be in range of 0.0-1.0. Defaults to 0.5 +# - "sdl" for joystick input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "axis_x": the index of the axis to bind as x-axis (default to 0) +# - "axis_y": the index of the axis to bind as y-axis (default to 1) +lstick= +rstick= + +# for motion input, the following devices are available: +# - "keyboard" (default) for emulating random motion input from buttons. Required parameters: +# - "code": the code of the key to bind +# - "sdl" for motion input using SDL. Required parameters: +# - "guid": SDL identification GUID of the joystick +# - "port": the index of the joystick to bind +# - "motion": the index of the motion sensor to bind +# - "cemuhookudp" for motion input using Cemu Hook protocol. Required parameters: +# - "guid": the IP address of the cemu hook server encoded to a hex string. for example 192.168.0.1 = "c0a80001" +# - "port": the port of the cemu hook server +# - "pad": the index of the joystick +# - "motion": the index of the motion sensor of the joystick to bind +motionleft= +motionright= + +[ControlsGeneral] +# To use the debug_pad, prepend `debug_pad_` before each button setting above. +# i.e. debug_pad_button_a= + +# Enable debug pad inputs to the guest +# 0 (default): Disabled, 1: Enabled +debug_pad_enabled = + +# Whether to enable or disable vibration +# 0: Disabled, 1 (default): Enabled +vibration_enabled= + +# Whether to enable or disable accurate vibrations +# 0 (default): Disabled, 1: Enabled +enable_accurate_vibrations= + +# Enables controller motion inputs +# 0: Disabled, 1 (default): Enabled +motion_enabled = + +# Defines the udp device's touch screen coordinate system for cemuhookudp devices +# - "min_x", "min_y", "max_x", "max_y" +touch_device= + +# for mapping buttons to touch inputs. +#touch_from_button_map=1 +#touch_from_button_maps_0_name=default +#touch_from_button_maps_0_count=2 +#touch_from_button_maps_0_bind_0=foo +#touch_from_button_maps_0_bind_1=bar +# etc. + +# List of Cemuhook UDP servers, delimited by ','. +# Default: 127.0.0.1:26760 +# Example: 127.0.0.1:26760,123.4.5.67:26761 +udp_input_servers = + +# Enable controlling an axis via a mouse input. +# 0 (default): Off, 1: On +mouse_panning = + +# Set mouse sensitivity. +# Default: 1.0 +mouse_panning_sensitivity = + +# Emulate an analog control stick from keyboard inputs. +# 0 (default): Disabled, 1: Enabled +emulate_analog_keyboard = + +# Enable mouse inputs to the guest +# 0 (default): Disabled, 1: Enabled +mouse_enabled = + +# Enable keyboard inputs to the guest +# 0 (default): Disabled, 1: Enabled +keyboard_enabled = + +[Core] +# Whether to use multi-core for CPU emulation +# 0: Disabled, 1 (default): Enabled +use_multi_core = + +# Enable unsafe extended guest system memory layout (8GB DRAM) +# 0 (default): Disabled, 1: Enabled +use_unsafe_extended_memory_layout = + +[Cpu] +# Adjusts various optimizations. +# Auto-select mode enables choice unsafe optimizations. +# Accurate enables only safe optimizations. +# Unsafe allows any unsafe optimizations. +# 0 (default): Auto-select, 1: Accurate, 2: Enable unsafe optimizations +cpu_accuracy = + +# Allow disabling safe optimizations. +# 0 (default): Disabled, 1: Enabled +cpu_debug_mode = + +# Enable inline page tables optimization (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_page_tables = + +# Enable block linking CPU optimization (reduce block dispatcher use during predictable jumps) +# 0: Disabled, 1 (default): Enabled +cpuopt_block_linking = + +# Enable return stack buffer CPU optimization (reduce block dispatcher use during predictable returns) +# 0: Disabled, 1 (default): Enabled +cpuopt_return_stack_buffer = + +# Enable fast dispatcher CPU optimization (use a two-tiered dispatcher architecture) +# 0: Disabled, 1 (default): Enabled +cpuopt_fast_dispatcher = + +# Enable context elimination CPU Optimization (reduce host memory use for guest context) +# 0: Disabled, 1 (default): Enabled +cpuopt_context_elimination = + +# Enable constant propagation CPU optimization (basic IR optimization) +# 0: Disabled, 1 (default): Enabled +cpuopt_const_prop = + +# Enable miscellaneous CPU optimizations (basic IR optimization) +# 0: Disabled, 1 (default): Enabled +cpuopt_misc_ir = + +# Enable reduction of memory misalignment checks (reduce memory fallbacks for misaligned access) +# 0: Disabled, 1 (default): Enabled +cpuopt_reduce_misalign_checks = + +# Enable Host MMU Emulation (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_fastmem = + +# Enable Host MMU Emulation for exclusive memory instructions (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_fastmem_exclusives = + +# Enable fallback on failure of fastmem of exclusive memory instructions (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_recompile_exclusives = + +# Enable optimization to ignore invalid memory accesses (faster guest memory access) +# 0: Disabled, 1 (default): Enabled +cpuopt_ignore_memory_aborts = + +# Enable unfuse FMA (improve performance on CPUs without FMA) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_unfuse_fma = + +# Enable faster FRSQRTE and FRECPE +# Only enabled if cpu_accuracy is set to Unsafe. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_reduce_fp_error = + +# Enable faster ASIMD instructions (32 bits only) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_ignore_standard_fpcr = + +# Enable inaccurate NaN handling +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_inaccurate_nan = + +# Disable address space checks (64 bits only) +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_fastmem_check = + +# Enable faster exclusive instructions +# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select. +# 0: Disabled, 1 (default): Enabled +cpuopt_unsafe_ignore_global_monitor = + +[Renderer] +# Which backend API to use. +# 0: OpenGL (unsupported), 1 (default): Vulkan, 2: Null +backend = + +# Whether to enable asynchronous presentation (Vulkan only) +# 0: Off, 1 (default): On +async_presentation = + +# Forces the GPU to run at the maximum possible clocks (thermal constraints will still be applied). +# 0 (default): Disabled, 1: Enabled +force_max_clock = + +# Enable graphics API debugging mode. +# 0 (default): Disabled, 1: Enabled +debug = + +# Enable shader feedback. +# 0 (default): Disabled, 1: Enabled +renderer_shader_feedback = + +# Enable Nsight Aftermath crash dumps +# 0 (default): Disabled, 1: Enabled +nsight_aftermath = + +# Disable shader loop safety checks, executing the shader without loop logic changes +# 0 (default): Disabled, 1: Enabled +disable_shader_loop_safety_checks = + +# Which Vulkan physical device to use (defaults to 0) +vulkan_device = + +# 0: 0.5x (360p/540p) [EXPERIMENTAL] +# 1: 0.75x (540p/810p) [EXPERIMENTAL] +# 2 (default): 1x (720p/1080p) +# 3: 2x (1440p/2160p) +# 4: 3x (2160p/3240p) +# 5: 4x (2880p/4320p) +# 6: 5x (3600p/5400p) +# 7: 6x (4320p/6480p) +resolution_setup = + +# Pixel filter to use when up- or down-sampling rendered frames. +# 0: Nearest Neighbor +# 1 (default): Bilinear +# 2: Bicubic +# 3: Gaussian +# 4: ScaleForce +# 5: AMD FidelityFX™️ Super Resolution [Vulkan Only] +scaling_filter = + +# Anti-Aliasing (AA) +# 0 (default): None, 1: FXAA +anti_aliasing = + +# Whether to use fullscreen or borderless window mode +# 0 (Windows default): Borderless window, 1 (All other default): Exclusive fullscreen +fullscreen_mode = + +# Aspect ratio +# 0: Default (16:9), 1: Force 4:3, 2: Force 21:9, 3: Force 16:10, 4: Stretch to Window +aspect_ratio = + +# Anisotropic filtering +# 0: Default, 1: 2x, 2: 4x, 3: 8x, 4: 16x +max_anisotropy = + +# Whether to enable VSync or not. +# OpenGL: Values other than 0 enable VSync +# Vulkan: FIFO is selected if the requested mode is not supported by the driver. +# FIFO (VSync) does not drop frames or exhibit tearing but is limited by the screen refresh rate. +# FIFO Relaxed is similar to FIFO but allows tearing as it recovers from a slow down. +# Mailbox can have lower latency than FIFO and does not tear but may drop frames. +# Immediate (no synchronization) just presents whatever is available and can exhibit tearing. +# 0: Immediate (Off), 1 (Default): Mailbox (On), 2: FIFO, 3: FIFO Relaxed +use_vsync = + +# Selects the OpenGL shader backend. NV_gpu_program5 is required for GLASM. If NV_gpu_program5 is +# not available and GLASM is selected, GLSL will be used. +# 0: GLSL, 1 (default): GLASM, 2: SPIR-V +shader_backend = + +# Whether to allow asynchronous shader building. +# 0 (default): Off, 1: On +use_asynchronous_shaders = + +# Uses reactive flushing instead of predictive flushing. Allowing a more accurate syncing of memory. +# 0 (default): Off, 1: On +use_reactive_flushing = + +# NVDEC emulation. +# 0: Disabled, 1: CPU Decoding, 2 (default): GPU Decoding +nvdec_emulation = + +# Accelerate ASTC texture decoding. +# 0 (default): Off, 1: On +accelerate_astc = + +# Turns on the speed limiter, which will limit the emulation speed to the desired speed limit value +# 0: Off, 1: On (default) +use_speed_limit = + +# Limits the speed of the game to run no faster than this value as a percentage of target speed +# 1 - 9999: Speed limit as a percentage of target game speed. 100 (default) +speed_limit = + +# Whether to use disk based shader cache +# 0: Off, 1 (default): On +use_disk_shader_cache = + +# Which gpu accuracy level to use +# 0 (default): Normal, 1: High, 2: Extreme (Very slow) +gpu_accuracy = + +# Whether to use asynchronous GPU emulation +# 0 : Off (slow), 1 (default): On (fast) +use_asynchronous_gpu_emulation = + +# Inform the guest that GPU operations completed more quickly than they did. +# 0: Off, 1 (default): On +use_fast_gpu_time = + +# Force unmodified buffers to be flushed, which can cost performance. +# 0: Off (default), 1: On +use_pessimistic_flushes = + +# Whether to use garbage collection or not for GPU caches. +# 0 (default): Off, 1: On +use_caches_gc = + +# The clear color for the renderer. What shows up on the sides of the bottom screen. +# Must be in range of 0-255. Defaults to 0 for all. +bg_red = +bg_blue = +bg_green = + +[Audio] +# Which audio output engine to use. +# auto (default): Auto-select +# cubeb: Cubeb audio engine (if available) +# sdl2: SDL2 audio engine (if available) +# null: No audio output +output_engine = + +# Which audio device to use. +# auto (default): Auto-select +output_device = + +# Output volume. +# 100 (default): 100%, 0; mute +volume = + +[Data Storage] +# Whether to create a virtual SD card. +# 1: Yes, 0 (default): No +use_virtual_sd = + +# Whether or not to enable gamecard emulation +# 1: Yes, 0 (default): No +gamecard_inserted = + +# Whether or not the gamecard should be emulated as the current game +# If 'gamecard_inserted' is 0 this setting is irrelevant +# 1: Yes, 0 (default): No +gamecard_current_game = + +# Path to an XCI file to use as the gamecard +# If 'gamecard_inserted' is 0 this setting is irrelevant +# If 'gamecard_current_game' is 1 this setting is irrelevant +gamecard_path = + +[System] +# Whether the system is docked +# 1 (default): Yes, 0: No +use_docked_mode = + +# Sets the seed for the RNG generator built into the switch +# rng_seed will be ignored and randomly generated if rng_seed_enabled is false +rng_seed_enabled = +rng_seed = + +# Sets the current time (in seconds since 12:00 AM Jan 1, 1970) that will be used by the time service +# This will auto-increment, with the time set being the time the game is started +# This override will only occur if custom_rtc_enabled is true, otherwise the current time is used +custom_rtc_enabled = +custom_rtc = + +# Sets the systems language index +# 0: Japanese, 1: English (default), 2: French, 3: German, 4: Italian, 5: Spanish, 6: Chinese, +# 7: Korean, 8: Dutch, 9: Portuguese, 10: Russian, 11: Taiwanese, 12: British English, 13: Canadian French, +# 14: Latin American Spanish, 15: Simplified Chinese, 16: Traditional Chinese, 17: Brazilian Portuguese +language_index = + +# The system region that sudachi will use during emulation +# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan +region_index = + +# The system time zone that sudachi will use during emulation +# 0: Auto-select (default), 1: Default (system archive value), Others: Index for specified time zone +time_zone_index = + +# Sets the sound output mode. +# 0: Mono, 1 (default): Stereo, 2: Surround +sound_index = + +[Miscellaneous] +# A filter which removes logs below a certain logging level. +# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical +log_filter = *:Trace + +# Use developer keys +# 0 (default): Disabled, 1: Enabled +use_dev_keys = + +[Debugging] +# Record frame time data, can be found in the log directory. Boolean value +record_frame_times = +# Determines whether or not sudachi will dump the ExeFS of all games it attempts to load while loading them +dump_exefs=false +# Determines whether or not sudachi will dump all NSOs it attempts to load while loading them +dump_nso=false +# Determines whether or not sudachi will save the filesystem access log. +enable_fs_access_log=false +# Enables verbose reporting services +reporting_services = +# Determines whether or not sudachi will report to the game that the emulated console is in Kiosk Mode +# false: Retail/Normal Mode (default), true: Kiosk Mode +quest_flag = +# Determines whether debug asserts should be enabled, which will throw an exception on asserts. +# false: Disabled (default), true: Enabled +use_debug_asserts = +# Determines whether unimplemented HLE service calls should be automatically stubbed. +# false: Disabled (default), true: Enabled +use_auto_stub = +# Enables/Disables the macro JIT compiler +disable_macro_jit=false +# Determines whether to enable the GDB stub and wait for the debugger to attach before running. +# false: Disabled (default), true: Enabled +use_gdbstub=false +# The port to use for the GDB server, if it is enabled. +gdbstub_port=6543 + +[WebService] +# Whether or not to enable telemetry +# 0: No, 1 (default): Yes +enable_telemetry = +# URL for Web API +web_api_url = https://api.sudachi-emu.org +# Username and token for sudachi Web Service +# See https://profile.sudachi-emu.org/ for more info +sudachi_username = +sudachi_token = + +[Network] +# Name of the network interface device to use with sudachi LAN play. +# e.g. On *nix: 'enp7s0', 'wlp6s0u1u3u3', 'lo' +# e.g. On Windows: 'Ethernet', 'Wi-Fi' +network_interface = + +[AddOns] +# Used to disable add-ons +# List of title IDs of games that will have add-ons disabled (separated by '|'): +title_ids = +# For each title ID, have a key/value pair called `disabled_` equal to the names of the add-ons to disable (sep. by '|') +# e.x. disabled_0100000000010000 = Update|DLC <- disables Updates and DLC on Super Mario Odyssey +)"; +} // namespace DefaultINI diff --git a/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp b/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp new file mode 100644 index 0000000..dae83bf --- /dev/null +++ b/src/android/sudachi/src/main/jni/emu_window/emu_window.cpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "common/android/id_cache.h" +#include "common/logging/log.h" +#include "input_common/drivers/android.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/drivers/virtual_gamepad.h" +#include "input_common/main.h" +#include "jni/emu_window/emu_window.h" +#include "jni/native.h" + +void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + m_window_width = ANativeWindow_getWidth(surface); + m_window_height = ANativeWindow_getHeight(surface); + + // Ensures that we emulate with the correct aspect ratio. + UpdateCurrentFramebufferLayout(m_window_width, m_window_height); + + window_info.render_surface = reinterpret_cast(surface); +} + +void EmuWindow_Android::OnTouchPressed(int id, float x, float y) { + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchPressed(touch_x, + touch_y, id); +} + +void EmuWindow_Android::OnTouchMoved(int id, float x, float y) { + const auto [touch_x, touch_y] = MapToTouchScreen(x, y); + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchMoved(touch_x, + touch_y, id); +} + +void EmuWindow_Android::OnTouchReleased(int id) { + EmulationSession::GetInstance().GetInputSubsystem().GetTouchScreen()->TouchReleased(id); +} + +void EmuWindow_Android::OnFrameDisplayed() { + if (!m_first_frame) { + Common::Android::RunJNIOnFiber( + [&](JNIEnv* env) { EmulationSession::GetInstance().OnEmulationStarted(); }); + m_first_frame = true; + } +} + +EmuWindow_Android::EmuWindow_Android(ANativeWindow* surface, + std::shared_ptr driver_library) + : m_driver_library{driver_library} { + LOG_INFO(Frontend, "initializing"); + + if (!surface) { + LOG_CRITICAL(Frontend, "surface is nullptr"); + return; + } + + OnSurfaceChanged(surface); + window_info.type = Core::Frontend::WindowSystemType::Android; +} diff --git a/src/android/sudachi/src/main/jni/id_cache.cpp b/src/android/sudachi/src/main/jni/id_cache.cpp new file mode 100644 index 0000000..f703fa8 --- /dev/null +++ b/src/android/sudachi/src/main/jni/id_cache.cpp @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: Copyright 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include "common/assert.h" +#include "common/fs/fs_android.h" +#include "jni/applets/software_keyboard.h" +#include "jni/id_cache.h" +#include "video_core/rasterizer_interface.h" + +static JavaVM* s_java_vm; +static jclass s_native_library_class; +static jclass s_disk_cache_progress_class; +static jclass s_load_callback_stage_class; +static jclass s_game_dir_class; +static jmethodID s_game_dir_constructor; +static jmethodID s_exit_emulation_activity; +static jmethodID s_disk_cache_load_progress; +static jmethodID s_on_emulation_started; +static jmethodID s_on_emulation_stopped; +static jmethodID s_on_program_changed; + +static jclass s_game_class; +static jmethodID s_game_constructor; +static jfieldID s_game_title_field; +static jfieldID s_game_path_field; +static jfieldID s_game_program_id_field; +static jfieldID s_game_developer_field; +static jfieldID s_game_version_field; +static jfieldID s_game_is_homebrew_field; + +static jclass s_string_class; +static jclass s_pair_class; +static jmethodID s_pair_constructor; +static jfieldID s_pair_first_field; +static jfieldID s_pair_second_field; + +static jclass s_overlay_control_data_class; +static jmethodID s_overlay_control_data_constructor; +static jfieldID s_overlay_control_data_id_field; +static jfieldID s_overlay_control_data_enabled_field; +static jfieldID s_overlay_control_data_landscape_position_field; +static jfieldID s_overlay_control_data_portrait_position_field; +static jfieldID s_overlay_control_data_foldable_position_field; + +static jclass s_patch_class; +static jmethodID s_patch_constructor; +static jfieldID s_patch_enabled_field; +static jfieldID s_patch_name_field; +static jfieldID s_patch_version_field; +static jfieldID s_patch_type_field; +static jfieldID s_patch_program_id_field; +static jfieldID s_patch_title_id_field; + +static jclass s_double_class; +static jmethodID s_double_constructor; +static jfieldID s_double_value_field; + +static jclass s_integer_class; +static jmethodID s_integer_constructor; +static jfieldID s_integer_value_field; + +static jclass s_boolean_class; +static jmethodID s_boolean_constructor; +static jfieldID s_boolean_value_field; + +static constexpr jint JNI_VERSION = JNI_VERSION_1_6; + +namespace IDCache { + +JNIEnv* GetEnvForThread() { + thread_local static struct OwnedEnv { + OwnedEnv() { + status = s_java_vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6); + if (status == JNI_EDETACHED) + s_java_vm->AttachCurrentThread(&env, nullptr); + } + + ~OwnedEnv() { + if (status == JNI_EDETACHED) + s_java_vm->DetachCurrentThread(); + } + + int status; + JNIEnv* env = nullptr; + } owned; + return owned.env; +} + +jclass GetNativeLibraryClass() { + return s_native_library_class; +} + +jclass GetDiskCacheProgressClass() { + return s_disk_cache_progress_class; +} + +jclass GetDiskCacheLoadCallbackStageClass() { + return s_load_callback_stage_class; +} + +jclass GetGameDirClass() { + return s_game_dir_class; +} + +jmethodID GetGameDirConstructor() { + return s_game_dir_constructor; +} + +jmethodID GetExitEmulationActivity() { + return s_exit_emulation_activity; +} + +jmethodID GetDiskCacheLoadProgress() { + return s_disk_cache_load_progress; +} + +jmethodID GetOnEmulationStarted() { + return s_on_emulation_started; +} + +jmethodID GetOnEmulationStopped() { + return s_on_emulation_stopped; +} + +jmethodID GetOnProgramChanged() { + return s_on_program_changed; +} + +jclass GetGameClass() { + return s_game_class; +} + +jmethodID GetGameConstructor() { + return s_game_constructor; +} + +jfieldID GetGameTitleField() { + return s_game_title_field; +} + +jfieldID GetGamePathField() { + return s_game_path_field; +} + +jfieldID GetGameProgramIdField() { + return s_game_program_id_field; +} + +jfieldID GetGameDeveloperField() { + return s_game_developer_field; +} + +jfieldID GetGameVersionField() { + return s_game_version_field; +} + +jfieldID GetGameIsHomebrewField() { + return s_game_is_homebrew_field; +} + +jclass GetStringClass() { + return s_string_class; +} + +jclass GetPairClass() { + return s_pair_class; +} + +jmethodID GetPairConstructor() { + return s_pair_constructor; +} + +jfieldID GetPairFirstField() { + return s_pair_first_field; +} + +jfieldID GetPairSecondField() { + return s_pair_second_field; +} + +jclass GetOverlayControlDataClass() { + return s_overlay_control_data_class; +} + +jmethodID GetOverlayControlDataConstructor() { + return s_overlay_control_data_constructor; +} + +jfieldID GetOverlayControlDataIdField() { + return s_overlay_control_data_id_field; +} + +jfieldID GetOverlayControlDataEnabledField() { + return s_overlay_control_data_enabled_field; +} + +jfieldID GetOverlayControlDataLandscapePositionField() { + return s_overlay_control_data_landscape_position_field; +} + +jfieldID GetOverlayControlDataPortraitPositionField() { + return s_overlay_control_data_portrait_position_field; +} + +jfieldID GetOverlayControlDataFoldablePositionField() { + return s_overlay_control_data_foldable_position_field; +} + +jclass GetPatchClass() { + return s_patch_class; +} + +jmethodID GetPatchConstructor() { + return s_patch_constructor; +} + +jfieldID GetPatchEnabledField() { + return s_patch_enabled_field; +} + +jfieldID GetPatchNameField() { + return s_patch_name_field; +} + +jfieldID GetPatchVersionField() { + return s_patch_version_field; +} + +jfieldID GetPatchTypeField() { + return s_patch_type_field; +} + +jfieldID GetPatchProgramIdField() { + return s_patch_program_id_field; +} + +jfieldID GetPatchTitleIdField() { + return s_patch_title_id_field; +} + +jclass GetDoubleClass() { + return s_double_class; +} + +jmethodID GetDoubleConstructor() { + return s_double_constructor; +} + +jfieldID GetDoubleValueField() { + return s_double_value_field; +} + +jclass GetIntegerClass() { + return s_integer_class; +} + +jmethodID GetIntegerConstructor() { + return s_integer_constructor; +} + +jfieldID GetIntegerValueField() { + return s_integer_value_field; +} + +jclass GetBooleanClass() { + return s_boolean_class; +} + +jmethodID GetBooleanConstructor() { + return s_boolean_constructor; +} + +jfieldID GetBooleanValueField() { + return s_boolean_value_field; +} + +} // namespace IDCache + +#ifdef __cplusplus +extern "C" { +#endif + +jint JNI_OnLoad(JavaVM* vm, void* reserved) { + s_java_vm = vm; + + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) + return JNI_ERR; + + // Initialize Java classes + const jclass native_library_class = env->FindClass("org/sudachi/sudachi_emu/NativeLibrary"); + s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class)); + s_disk_cache_progress_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress"))); + s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass( + "org/sudachi/sudachi_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); + + const jclass game_dir_class = env->FindClass("org/sudachi/sudachi_emu/model/GameDir"); + s_game_dir_class = reinterpret_cast(env->NewGlobalRef(game_dir_class)); + s_game_dir_constructor = env->GetMethodID(game_dir_class, "", "(Ljava/lang/String;Z)V"); + env->DeleteLocalRef(game_dir_class); + + // Initialize methods + s_exit_emulation_activity = + env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); + s_disk_cache_load_progress = + env->GetStaticMethodID(s_disk_cache_progress_class, "loadProgress", "(III)V"); + s_on_emulation_started = + env->GetStaticMethodID(s_native_library_class, "onEmulationStarted", "()V"); + s_on_emulation_stopped = + env->GetStaticMethodID(s_native_library_class, "onEmulationStopped", "(I)V"); + s_on_program_changed = + env->GetStaticMethodID(s_native_library_class, "onProgramChanged", "(I)V"); + + const jclass game_class = env->FindClass("org/sudachi/sudachi_emu/model/Game"); + s_game_class = reinterpret_cast(env->NewGlobalRef(game_class)); + s_game_constructor = env->GetMethodID(game_class, "", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/" + "String;Ljava/lang/String;Ljava/lang/String;Z)V"); + s_game_title_field = env->GetFieldID(game_class, "title", "Ljava/lang/String;"); + s_game_path_field = env->GetFieldID(game_class, "path", "Ljava/lang/String;"); + s_game_program_id_field = env->GetFieldID(game_class, "programId", "Ljava/lang/String;"); + s_game_developer_field = env->GetFieldID(game_class, "developer", "Ljava/lang/String;"); + s_game_version_field = env->GetFieldID(game_class, "version", "Ljava/lang/String;"); + s_game_is_homebrew_field = env->GetFieldID(game_class, "isHomebrew", "Z"); + env->DeleteLocalRef(game_class); + + const jclass string_class = env->FindClass("java/lang/String"); + s_string_class = reinterpret_cast(env->NewGlobalRef(string_class)); + env->DeleteLocalRef(string_class); + + const jclass pair_class = env->FindClass("kotlin/Pair"); + s_pair_class = reinterpret_cast(env->NewGlobalRef(pair_class)); + s_pair_constructor = + env->GetMethodID(pair_class, "", "(Ljava/lang/Object;Ljava/lang/Object;)V"); + s_pair_first_field = env->GetFieldID(pair_class, "first", "Ljava/lang/Object;"); + s_pair_second_field = env->GetFieldID(pair_class, "second", "Ljava/lang/Object;"); + env->DeleteLocalRef(pair_class); + + const jclass overlay_control_data_class = + env->FindClass("org/sudachi/sudachi_emu/overlay/model/OverlayControlData"); + s_overlay_control_data_class = + reinterpret_cast(env->NewGlobalRef(overlay_control_data_class)); + s_overlay_control_data_constructor = + env->GetMethodID(overlay_control_data_class, "", + "(Ljava/lang/String;ZLkotlin/Pair;Lkotlin/Pair;Lkotlin/Pair;)V"); + s_overlay_control_data_id_field = + env->GetFieldID(overlay_control_data_class, "id", "Ljava/lang/String;"); + s_overlay_control_data_enabled_field = + env->GetFieldID(overlay_control_data_class, "enabled", "Z"); + s_overlay_control_data_landscape_position_field = + env->GetFieldID(overlay_control_data_class, "landscapePosition", "Lkotlin/Pair;"); + s_overlay_control_data_portrait_position_field = + env->GetFieldID(overlay_control_data_class, "portraitPosition", "Lkotlin/Pair;"); + s_overlay_control_data_foldable_position_field = + env->GetFieldID(overlay_control_data_class, "foldablePosition", "Lkotlin/Pair;"); + env->DeleteLocalRef(overlay_control_data_class); + + const jclass patch_class = env->FindClass("org/sudachi/sudachi_emu/model/Patch"); + s_patch_class = reinterpret_cast(env->NewGlobalRef(patch_class)); + s_patch_constructor = env->GetMethodID( + patch_class, "", + "(ZLjava/lang/String;Ljava/lang/String;ILjava/lang/String;Ljava/lang/String;)V"); + s_patch_enabled_field = env->GetFieldID(patch_class, "enabled", "Z"); + s_patch_name_field = env->GetFieldID(patch_class, "name", "Ljava/lang/String;"); + s_patch_version_field = env->GetFieldID(patch_class, "version", "Ljava/lang/String;"); + s_patch_type_field = env->GetFieldID(patch_class, "type", "I"); + s_patch_program_id_field = env->GetFieldID(patch_class, "programId", "Ljava/lang/String;"); + s_patch_title_id_field = env->GetFieldID(patch_class, "titleId", "Ljava/lang/String;"); + env->DeleteLocalRef(patch_class); + + const jclass double_class = env->FindClass("java/lang/Double"); + s_double_class = reinterpret_cast(env->NewGlobalRef(double_class)); + s_double_constructor = env->GetMethodID(double_class, "", "(D)V"); + s_double_value_field = env->GetFieldID(double_class, "value", "D"); + env->DeleteLocalRef(double_class); + + const jclass int_class = env->FindClass("java/lang/Integer"); + s_integer_class = reinterpret_cast(env->NewGlobalRef(int_class)); + s_integer_constructor = env->GetMethodID(int_class, "", "(I)V"); + s_integer_value_field = env->GetFieldID(int_class, "value", "I"); + env->DeleteLocalRef(int_class); + + const jclass boolean_class = env->FindClass("java/lang/Boolean"); + s_boolean_class = reinterpret_cast(env->NewGlobalRef(boolean_class)); + s_boolean_constructor = env->GetMethodID(boolean_class, "", "(Z)V"); + s_boolean_value_field = env->GetFieldID(boolean_class, "value", "Z"); + env->DeleteLocalRef(boolean_class); + + // Initialize Android Storage + Common::FS::Android::RegisterCallbacks(env, s_native_library_class); + + // Initialize applets + SoftwareKeyboard::InitJNI(env); + + return JNI_VERSION; +} + +void JNI_OnUnload(JavaVM* vm, void* reserved) { + JNIEnv* env; + if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION) != JNI_OK) { + return; + } + + // UnInitialize Android Storage + Common::FS::Android::UnRegisterCallbacks(); + env->DeleteGlobalRef(s_native_library_class); + env->DeleteGlobalRef(s_disk_cache_progress_class); + env->DeleteGlobalRef(s_load_callback_stage_class); + env->DeleteGlobalRef(s_game_dir_class); + env->DeleteGlobalRef(s_game_class); + env->DeleteGlobalRef(s_string_class); + env->DeleteGlobalRef(s_pair_class); + env->DeleteGlobalRef(s_overlay_control_data_class); + env->DeleteGlobalRef(s_patch_class); + env->DeleteGlobalRef(s_double_class); + env->DeleteGlobalRef(s_integer_class); + env->DeleteGlobalRef(s_boolean_class); + + // UnInitialize applets + SoftwareKeyboard::CleanupJNI(env); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/android/sudachi/src/main/jni/native_config.cpp b/src/android/sudachi/src/main/jni/native_config.cpp new file mode 100644 index 0000000..30f8dab --- /dev/null +++ b/src/android/sudachi/src/main/jni/native_config.cpp @@ -0,0 +1,543 @@ +// SPDX-FileCopyrightText: 2023 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include + +#include + +#include "android_config.h" +#include "android_settings.h" +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "frontend_common/config.h" +#include "native.h" + +std::unique_ptr global_config; +std::unique_ptr per_game_config; + +template +Settings::Setting* getSetting(JNIEnv* env, jstring jkey) { + auto key = Common::Android::GetJString(env, jkey); + auto basic_setting = Settings::values.linkage.by_key[key]; + if (basic_setting != 0) { + return static_cast*>(basic_setting); + } + auto basic_android_setting = AndroidSettings::values.linkage.by_key[key]; + if (basic_android_setting != 0) { + return static_cast*>(basic_android_setting); + } + LOG_ERROR(Frontend, "[Android Native] Could not find setting - {}", key); + return nullptr; +} + +extern "C" { + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_initializeGlobalConfig(JNIEnv* env, jobject obj) { + global_config = std::make_unique(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_unloadGlobalConfig(JNIEnv* env, jobject obj) { + global_config.reset(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_reloadGlobalConfig(JNIEnv* env, jobject obj) { + global_config->AndroidConfig::ReloadAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_saveGlobalConfig(JNIEnv* env, jobject obj) { + global_config->AndroidConfig::SaveAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_initializePerGameConfig(JNIEnv* env, jobject obj, + jstring jprogramId, + jstring jfileName) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + auto file_name = Common::Android::GetJString(env, jfileName); + const auto config_file_name = program_id == 0 ? file_name : fmt::format("{:016X}", program_id); + per_game_config = + std::make_unique(config_file_name, Config::ConfigType::PerGameConfig); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_isPerGameConfigLoaded(JNIEnv* env, + jobject obj) { + return per_game_config != nullptr; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_savePerGameConfig(JNIEnv* env, jobject obj) { + per_game_config->AndroidConfig::SaveAllValues(); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_unloadPerGameConfig(JNIEnv* env, jobject obj) { + per_game_config.reset(); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getBoolean(JNIEnv* env, jobject obj, + jstring jkey, jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return false; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setBoolean(JNIEnv* env, jobject obj, jstring jkey, + jboolean value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(static_cast(value)); +} + +jbyte Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getByte(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setByte(JNIEnv* env, jobject obj, jstring jkey, + jbyte value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jshort Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getShort(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setShort(JNIEnv* env, jobject obj, jstring jkey, + jshort value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jint Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getInt(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(needGlobal); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setInt(JNIEnv* env, jobject obj, jstring jkey, + jint value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jfloat Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getFloat(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setFloat(JNIEnv* env, jobject obj, jstring jkey, + jfloat value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jlong Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getLong(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return -1; + } + return setting->GetValue(static_cast(needGlobal)); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setLong(JNIEnv* env, jobject obj, jstring jkey, + jlong value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + setting->SetValue(value); +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getString(JNIEnv* env, jobject obj, jstring jkey, + jboolean needGlobal) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return Common::Android::ToJString(env, ""); + } + return Common::Android::ToJString(env, setting->GetValue(static_cast(needGlobal))); +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setString(JNIEnv* env, jobject obj, jstring jkey, + jstring value) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return; + } + + setting->SetValue(Common::Android::GetJString(env, value)); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsRuntimeModifiable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->RuntimeModifiable(); + } + return true; +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getPairedSettingKey(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting == nullptr) { + return Common::Android::ToJString(env, ""); + } + if (setting->PairedSetting() == nullptr) { + return Common::Android::ToJString(env, ""); + } + + return Common::Android::ToJString(env, setting->PairedSetting()->GetLabel()); +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsSwitchable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->Switchable(); + } + return false; +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_usingGlobal(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->UsingGlobal(); + } + return true; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setGlobal(JNIEnv* env, jobject obj, jstring jkey, + jboolean global) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + setting->SetGlobal(static_cast(global)); + } +} + +jboolean Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getIsSaveable(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return setting->Save(); + } + return false; +} + +jstring Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getDefaultToString(JNIEnv* env, jobject obj, + jstring jkey) { + auto setting = getSetting(env, jkey); + if (setting != nullptr) { + return Common::Android::ToJString(env, setting->DefaultToString()); + } + return Common::Android::ToJString(env, ""); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getGameDirs(JNIEnv* env, jobject obj) { + jclass gameDirClass = Common::Android::GetGameDirClass(); + jmethodID gameDirConstructor = Common::Android::GetGameDirConstructor(); + jobjectArray jgameDirArray = + env->NewObjectArray(AndroidSettings::values.game_dirs.size(), gameDirClass, nullptr); + for (size_t i = 0; i < AndroidSettings::values.game_dirs.size(); ++i) { + jobject jgameDir = env->NewObject( + gameDirClass, gameDirConstructor, + Common::Android::ToJString(env, AndroidSettings::values.game_dirs[i].path), + static_cast(AndroidSettings::values.game_dirs[i].deep_scan)); + env->SetObjectArrayElement(jgameDirArray, i, jgameDir); + } + return jgameDirArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setGameDirs(JNIEnv* env, jobject obj, + jobjectArray gameDirs) { + AndroidSettings::values.game_dirs.clear(); + int size = env->GetArrayLength(gameDirs); + + if (size == 0) { + return; + } + + jobject dir = env->GetObjectArrayElement(gameDirs, 0); + jclass gameDirClass = Common::Android::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + for (int i = 0; i < size; ++i) { + dir = env->GetObjectArrayElement(gameDirs, i); + jstring juriString = static_cast(env->GetObjectField(dir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(dir, deepScanBooleanField); + std::string uriString = Common::Android::GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); + } +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_addGameDir(JNIEnv* env, jobject obj, + jobject gameDir) { + jclass gameDirClass = Common::Android::GetGameDirClass(); + jfieldID uriStringField = env->GetFieldID(gameDirClass, "uriString", "Ljava/lang/String;"); + jfieldID deepScanBooleanField = env->GetFieldID(gameDirClass, "deepScan", "Z"); + + jstring juriString = static_cast(env->GetObjectField(gameDir, uriStringField)); + jboolean jdeepScanBoolean = env->GetBooleanField(gameDir, deepScanBooleanField); + std::string uriString = Common::Android::GetJString(env, juriString); + AndroidSettings::values.game_dirs.push_back( + AndroidSettings::GameDir{uriString, static_cast(jdeepScanBoolean)}); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + auto& disabledAddons = Settings::values.disabled_addons[program_id]; + jobjectArray jdisabledAddonsArray = + env->NewObjectArray(disabledAddons.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < disabledAddons.size(); ++i) { + env->SetObjectArrayElement(jdisabledAddonsArray, i, + Common::Android::ToJString(env, disabledAddons[i])); + } + return jdisabledAddonsArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setDisabledAddons(JNIEnv* env, jobject obj, + jstring jprogramId, + jobjectArray jdisabledAddons) { + auto program_id = EmulationSession::GetProgramId(env, jprogramId); + Settings::values.disabled_addons[program_id].clear(); + std::vector disabled_addons; + const int size = env->GetArrayLength(jdisabledAddons); + for (int i = 0; i < size; ++i) { + auto jaddon = static_cast(env->GetObjectArrayElement(jdisabledAddons, i)); + disabled_addons.push_back(Common::Android::GetJString(env, jaddon)); + } + Settings::values.disabled_addons[program_id] = disabled_addons; +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getOverlayControlData(JNIEnv* env, + jobject obj) { + jobjectArray joverlayControlDataArray = + env->NewObjectArray(AndroidSettings::values.overlay_control_data.size(), + Common::Android::GetOverlayControlDataClass(), nullptr); + for (size_t i = 0; i < AndroidSettings::values.overlay_control_data.size(); ++i) { + const auto& control_data = AndroidSettings::values.overlay_control_data[i]; + jobject jlandscapePosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.landscape_position.first), + Common::Android::ToJDouble(env, control_data.landscape_position.second)); + jobject jportraitPosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.portrait_position.first), + Common::Android::ToJDouble(env, control_data.portrait_position.second)); + jobject jfoldablePosition = + env->NewObject(Common::Android::GetPairClass(), Common::Android::GetPairConstructor(), + Common::Android::ToJDouble(env, control_data.foldable_position.first), + Common::Android::ToJDouble(env, control_data.foldable_position.second)); + + jobject jcontrolData = + env->NewObject(Common::Android::GetOverlayControlDataClass(), + Common::Android::GetOverlayControlDataConstructor(), + Common::Android::ToJString(env, control_data.id), control_data.enabled, + jlandscapePosition, jportraitPosition, jfoldablePosition); + env->SetObjectArrayElement(joverlayControlDataArray, i, jcontrolData); + } + return joverlayControlDataArray; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setOverlayControlData( + JNIEnv* env, jobject obj, jobjectArray joverlayControlDataArray) { + AndroidSettings::values.overlay_control_data.clear(); + int size = env->GetArrayLength(joverlayControlDataArray); + + if (size == 0) { + return; + } + + for (int i = 0; i < size; ++i) { + jobject joverlayControlData = env->GetObjectArrayElement(joverlayControlDataArray, i); + jstring jidString = static_cast(env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataIdField())); + bool enabled = static_cast(env->GetBooleanField( + joverlayControlData, Common::Android::GetOverlayControlDataEnabledField())); + + jobject jlandscapePosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataLandscapePositionField()); + std::pair landscape_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jlandscapePosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jlandscapePosition, Common::Android::GetPairSecondField()))); + + jobject jportraitPosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataPortraitPositionField()); + std::pair portrait_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jportraitPosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jportraitPosition, Common::Android::GetPairSecondField()))); + + jobject jfoldablePosition = env->GetObjectField( + joverlayControlData, Common::Android::GetOverlayControlDataFoldablePositionField()); + std::pair foldable_position = std::make_pair( + Common::Android::GetJDouble( + env, env->GetObjectField(jfoldablePosition, Common::Android::GetPairFirstField())), + Common::Android::GetJDouble( + env, + env->GetObjectField(jfoldablePosition, Common::Android::GetPairSecondField()))); + + AndroidSettings::values.overlay_control_data.push_back(AndroidSettings::OverlayControlData{ + Common::Android::GetJString(env, jidString), enabled, landscape_position, + portrait_position, foldable_position}); + } +} + +jobjectArray Java_org_sudachi_sudachi_1emu_utils_NativeConfig_getInputSettings(JNIEnv* env, jobject obj, + jboolean j_global) { + Settings::values.players.SetGlobal(static_cast(j_global)); + auto& players = Settings::values.players.GetValue(); + jobjectArray j_input_settings = + env->NewObjectArray(players.size(), Common::Android::GetPlayerInputClass(), nullptr); + for (size_t i = 0; i < players.size(); ++i) { + auto j_connected = static_cast(players[i].connected); + + jobjectArray j_buttons = env->NewObjectArray( + players[i].buttons.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].buttons.size(); ++j) { + env->SetObjectArrayElement(j_buttons, j, + Common::Android::ToJString(env, players[i].buttons[j])); + } + jobjectArray j_analogs = env->NewObjectArray( + players[i].analogs.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].analogs.size(); ++j) { + env->SetObjectArrayElement(j_analogs, j, + Common::Android::ToJString(env, players[i].analogs[j])); + } + jobjectArray j_motions = env->NewObjectArray( + players[i].motions.size(), Common::Android::GetStringClass(), env->NewStringUTF("")); + for (size_t j = 0; j < players[i].motions.size(); ++j) { + env->SetObjectArrayElement(j_motions, j, + Common::Android::ToJString(env, players[i].motions[j])); + } + + auto j_vibration_enabled = static_cast(players[i].vibration_enabled); + auto j_vibration_strength = static_cast(players[i].vibration_strength); + + auto j_body_color_left = static_cast(players[i].body_color_left); + auto j_body_color_right = static_cast(players[i].body_color_right); + auto j_button_color_left = static_cast(players[i].button_color_left); + auto j_button_color_right = static_cast(players[i].button_color_right); + + auto j_profile_name = Common::Android::ToJString(env, players[i].profile_name); + + auto j_use_system_vibrator = players[i].use_system_vibrator; + + jobject playerInput = env->NewObject( + Common::Android::GetPlayerInputClass(), Common::Android::GetPlayerInputConstructor(), + j_connected, j_buttons, j_analogs, j_motions, j_vibration_enabled, j_vibration_strength, + j_body_color_left, j_body_color_right, j_button_color_left, j_button_color_right, + j_profile_name, j_use_system_vibrator); + env->SetObjectArrayElement(j_input_settings, i, playerInput); + } + return j_input_settings; +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_setInputSettings(JNIEnv* env, jobject obj, + jobjectArray j_value, + jboolean j_global) { + auto& players = Settings::values.players.GetValue(static_cast(j_global)); + int playersSize = env->GetArrayLength(j_value); + for (int i = 0; i < playersSize; ++i) { + jobject jplayer = env->GetObjectArrayElement(j_value, i); + + players[i].connected = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputConnectedField())); + + auto j_buttons_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputButtonsField())); + int buttons_size = env->GetArrayLength(j_buttons_array); + for (int j = 0; j < buttons_size; ++j) { + auto button = static_cast(env->GetObjectArrayElement(j_buttons_array, j)); + players[i].buttons[j] = Common::Android::GetJString(env, button); + } + auto j_analogs_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputAnalogsField())); + int analogs_size = env->GetArrayLength(j_analogs_array); + for (int j = 0; j < analogs_size; ++j) { + auto analog = static_cast(env->GetObjectArrayElement(j_analogs_array, j)); + players[i].analogs[j] = Common::Android::GetJString(env, analog); + } + auto j_motions_array = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputMotionsField())); + int motions_size = env->GetArrayLength(j_motions_array); + for (int j = 0; j < motions_size; ++j) { + auto motion = static_cast(env->GetObjectArrayElement(j_motions_array, j)); + players[i].motions[j] = Common::Android::GetJString(env, motion); + } + + players[i].vibration_enabled = static_cast( + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputVibrationEnabledField())); + players[i].vibration_strength = static_cast( + env->GetIntField(jplayer, Common::Android::GetPlayerInputVibrationStrengthField())); + + players[i].body_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorLeftField())); + players[i].body_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputBodyColorRightField())); + players[i].button_color_left = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorLeftField())); + players[i].button_color_right = static_cast( + env->GetLongField(jplayer, Common::Android::GetPlayerInputButtonColorRightField())); + + auto profileName = static_cast( + env->GetObjectField(jplayer, Common::Android::GetPlayerInputProfileNameField())); + players[i].profile_name = Common::Android::GetJString(env, profileName); + + players[i].use_system_vibrator = + env->GetBooleanField(jplayer, Common::Android::GetPlayerInputUseSystemVibratorField()); + } +} + +void Java_org_sudachi_sudachi_1emu_utils_NativeConfig_saveControlPlayerValues(JNIEnv* env, jobject obj) { + Settings::values.players.SetGlobal(false); + + // Clear all controls from the config in case the user reverted back to globals + per_game_config->ClearControlPlayerValues(); + for (size_t index = 0; index < Settings::values.players.GetValue().size(); ++index) { + per_game_config->SaveAndroidControlPlayerValues(index); + } +} + +} // extern "C" diff --git a/src/android/sudachi/src/main/jni/native_input.cpp b/src/android/sudachi/src/main/jni/native_input.cpp new file mode 100644 index 0000000..0ff784c --- /dev/null +++ b/src/android/sudachi/src/main/jni/native_input.cpp @@ -0,0 +1,638 @@ +// SPDX-FileCopyrightText: 2024 sudachi Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include "android_config.h" +#include "common/android/android_common.h" +#include "common/android/id_cache.h" +#include "hid_core/frontend/emulated_controller.h" +#include "hid_core/hid_core.h" +#include "input_common/drivers/android.h" +#include "input_common/drivers/touch_screen.h" +#include "input_common/drivers/virtual_amiibo.h" +#include "input_common/drivers/virtual_gamepad.h" +#include "native.h" + +std::unordered_map> map_profiles; + +bool IsHandheldOnly() { + const auto npad_style_set = + EmulationSession::GetInstance().System().HIDCore().GetSupportedStyleTag(); + + if (npad_style_set.fullkey == 1) { + return false; + } + + if (npad_style_set.handheld == 0) { + return false; + } + + return !Settings::IsDockedMode(); +} + +std::filesystem::path GetNameWithoutExtension(std::filesystem::path filename) { + return filename.replace_extension(); +} + +bool IsProfileNameValid(std::string_view profile_name) { + return profile_name.find_first_of("<>:;\"/\\|,.!?*") == std::string::npos; +} + +bool ProfileExistsInFilesystem(std::string_view profile_name) { + return Common::FS::Exists(Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / + "input" / fmt::format("{}.ini", profile_name)); +} + +bool ProfileExistsInMap(const std::string& profile_name) { + return map_profiles.find(profile_name) != map_profiles.end(); +} + +bool SaveProfile(const std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->SaveAndroidControlPlayerValues(player_index); + return true; +} + +bool LoadProfile(std::string& profile_name, std::size_t player_index) { + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name)) { + map_profiles.erase(profile_name); + return false; + } + + LOG_INFO(Config, "Loading input profile `{}`", profile_name); + + Settings::values.players.GetValue()[player_index].profile_name = profile_name; + map_profiles[profile_name]->ReadAndroidControlPlayerValues(player_index); + return true; +} + +void ApplyControllerConfig(size_t player_index, + const std::function& apply) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + apply(handheld); + apply(player_one); + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + apply(controller); + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +std::vector GetSupportedStyles(int player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + const auto npad_style_set = hid_core.GetSupportedStyleTag(); + std::vector supported_indexes; + if (npad_style_set.fullkey == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Fullkey)); + } + + if (npad_style_set.joycon_dual == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconDual)); + } + + if (npad_style_set.joycon_left == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconLeft)); + } + + if (npad_style_set.joycon_right == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::JoyconRight)); + } + + if (player_index == 0 && npad_style_set.handheld == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::Handheld)); + } + + if (npad_style_set.gamecube == 1) { + supported_indexes.push_back(static_cast(Core::HID::NpadStyleIndex::GameCube)); + } + + return supported_indexes; +} + +void ConnectController(size_t player_index, bool connected) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + ApplyControllerConfig(player_index, [&](Core::HID::EmulatedController* controller) { + auto supported_styles = GetSupportedStyles(player_index); + auto controller_style = controller->GetNpadStyleIndex(true); + auto style = std::find(supported_styles.begin(), supported_styles.end(), + static_cast(controller_style)); + if (style == supported_styles.end() && !supported_styles.empty()) { + controller->SetNpadStyleIndex( + static_cast(supported_styles[0])); + } + }); + + if (player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + handheld->EnableConfiguration(); + player_one->EnableConfiguration(); + if (player_one->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + if (connected) { + handheld->Connect(); + } else { + handheld->Disconnect(); + } + player_one->Disconnect(); + } else { + if (connected) { + player_one->Connect(); + } else { + player_one->Disconnect(); + } + handheld->Disconnect(); + } + handheld->DisableConfiguration(); + player_one->DisableConfiguration(); + handheld->SaveCurrentConfig(); + player_one->SaveCurrentConfig(); + } else { + auto* controller = hid_core.GetEmulatedControllerByIndex(player_index); + controller->EnableConfiguration(); + if (connected) { + controller->Connect(); + } else { + controller->Disconnect(); + } + controller->DisableConfiguration(); + controller->SaveCurrentConfig(); + } +} + +extern "C" { + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isHandheldOnly(JNIEnv* env, + jobject j_obj) { + return IsHandheldOnly(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadButtonEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_button_id, jint j_action) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetButtonState( + Common::Android::GetJString(env, j_guid), j_port, j_button_id, j_action != 0); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadAxisEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jint j_stick_id, jfloat j_value) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetAxisPosition( + Common::Android::GetJString(env, j_guid), j_port, j_stick_id, j_value); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onGamePadMotionEvent( + JNIEnv* env, jobject j_obj, jstring j_guid, jint j_port, jlong j_delta_timestamp, + jfloat j_x_gyro, jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, + jfloat j_z_accel) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->SetMotionState( + Common::Android::GetJString(env, j_guid), j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, + j_z_gyro, j_x_accel, j_y_accel, j_z_accel); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onReadNfcTag(JNIEnv* env, + jobject j_obj, + jbyteArray j_data) { + jboolean isCopy{false}; + std::span data(reinterpret_cast(env->GetByteArrayElements(j_data, &isCopy)), + static_cast(env->GetArrayLength(j_data))); + + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->LoadAmiibo(data); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onRemoveNfcTag(JNIEnv* env, + jobject j_obj) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualAmiibo()->CloseAmiibo(); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchPressed( + JNIEnv* env, jobject j_obj, jint j_id, jfloat j_x_axis, jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchPressed(j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchMoved(JNIEnv* env, + jobject j_obj, jint j_id, + jfloat j_x_axis, + jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchMoved(j_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onTouchReleased(JNIEnv* env, + jobject j_obj, + jint j_id) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchReleased(j_id); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onOverlayButtonEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_button_id, jint j_action) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetButtonState( + j_port, j_button_id, j_action == 1); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onOverlayJoystickEventImpl( + JNIEnv* env, jobject j_obj, jint j_port, jint j_stick_id, jfloat j_x_axis, jfloat j_y_axis) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetStickPosition( + j_port, j_stick_id, j_x_axis, j_y_axis); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_onDeviceMotionEvent( + JNIEnv* env, jobject j_obj, jint j_port, jlong j_delta_timestamp, jfloat j_x_gyro, + jfloat j_y_gyro, jfloat j_z_gyro, jfloat j_x_accel, jfloat j_y_accel, jfloat j_z_accel) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().GetInputSubsystem().GetVirtualGamepad()->SetMotionState( + j_port, j_delta_timestamp, j_x_gyro, j_y_gyro, j_z_gyro, j_x_accel, j_y_accel, + j_z_accel); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_reloadInputDevices(JNIEnv* env, + jobject j_obj) { + EmulationSession::GetInstance().System().HIDCore().ReloadInputDevices(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_registerController(JNIEnv* env, + jobject j_obj, + jobject j_device) { + EmulationSession::GetInstance().GetInputSubsystem().GetAndroid()->RegisterController(j_device); +} + +jobjectArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getInputDevices( + JNIEnv* env, jobject j_obj) { + auto devices = EmulationSession::GetInstance().GetInputSubsystem().GetInputDevices(); + jobjectArray jdevices = env->NewObjectArray(devices.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < devices.size(); ++i) { + env->SetObjectArrayElement(jdevices, i, + Common::Android::ToJString(env, devices[i].Serialize())); + } + return jdevices; +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadInputProfiles(JNIEnv* env, + jobject j_obj) { + map_profiles.clear(); + const auto input_profile_loc = + Common::FS::GetSudachiPath(Common::FS::SudachiPath::ConfigDir) / "input"; + + if (Common::FS::IsDir(input_profile_loc)) { + Common::FS::IterateDirEntries( + input_profile_loc, + [&](const std::filesystem::path& full_path) { + const auto filename = full_path.filename(); + const auto name_without_ext = + Common::FS::PathToUTF8String(GetNameWithoutExtension(filename)); + + if (filename.extension() == ".ini" && IsProfileNameValid(name_without_ext)) { + map_profiles.insert_or_assign( + name_without_ext, std::make_unique( + name_without_ext, Config::ConfigType::InputProfile)); + } + + return true; + }, + Common::FS::DirEntryFilter::File); + } +} + +jobjectArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getInputProfileNames( + JNIEnv* env, jobject j_obj) { + std::vector profile_names; + profile_names.reserve(map_profiles.size()); + + auto it = map_profiles.cbegin(); + while (it != map_profiles.cend()) { + const auto& [profile_name, config] = *it; + if (!ProfileExistsInFilesystem(profile_name)) { + it = map_profiles.erase(it); + continue; + } + + profile_names.push_back(profile_name); + ++it; + } + + std::stable_sort(profile_names.begin(), profile_names.end()); + + jobjectArray j_profile_names = + env->NewObjectArray(profile_names.size(), Common::Android::GetStringClass(), + Common::Android::ToJString(env, "")); + for (size_t i = 0; i < profile_names.size(); ++i) { + env->SetObjectArrayElement(j_profile_names, i, + Common::Android::ToJString(env, profile_names[i])); + } + + return j_profile_names; +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isProfileNameValid( + JNIEnv* env, jobject j_obj, jstring j_name) { + return Common::Android::GetJString(env, j_name).find_first_of("<>:;\"/\\|,.!?*") == + std::string::npos; +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_createProfile( + JNIEnv* env, jobject j_obj, jstring j_name, jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (ProfileExistsInMap(profile_name)) { + return false; + } + + map_profiles.insert_or_assign( + profile_name, + std::make_unique(profile_name, Config::ConfigType::InputProfile)); + + return SaveProfile(profile_name, j_player_index); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_deleteProfile( + JNIEnv* env, jobject j_obj, jstring j_name, jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + if (!ProfileExistsInMap(profile_name)) { + return false; + } + + if (!ProfileExistsInFilesystem(profile_name) || + Common::FS::RemoveFile(map_profiles[profile_name]->GetConfigFilePath())) { + map_profiles.erase(profile_name); + } + + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + return !ProfileExistsInMap(profile_name) && !ProfileExistsInFilesystem(profile_name); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return LoadProfile(profile_name, j_player_index); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_saveProfile(JNIEnv* env, + jobject j_obj, + jstring j_name, + jint j_player_index) { + auto profile_name = Common::Android::GetJString(env, j_name); + return SaveProfile(profile_name, j_player_index); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_loadPerGameConfiguration( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_selected_index, + jstring j_selected_profile_name) { + static constexpr size_t HANDHELD_INDEX = 8; + + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + Settings::values.players.SetGlobal(false); + + auto profile_name = Common::Android::GetJString(env, j_selected_profile_name); + auto* emulated_controller = hid_core.GetEmulatedControllerByIndex(j_player_index); + + if (j_selected_index == 0) { + Settings::values.players.GetValue()[j_player_index].profile_name = ""; + if (j_player_index == 0) { + Settings::values.players.GetValue()[HANDHELD_INDEX] = {}; + } + Settings::values.players.SetGlobal(true); + emulated_controller->ReloadFromSettings(); + return; + } + if (profile_name.empty()) { + return; + } + auto& player = Settings::values.players.GetValue()[j_player_index]; + auto& global_player = Settings::values.players.GetValue(true)[j_player_index]; + player.profile_name = profile_name; + global_player.profile_name = profile_name; + // Read from the profile into the custom player settings + LoadProfile(profile_name, j_player_index); + // Make sure the controller is connected + player.connected = true; + + emulated_controller->ReloadFromSettings(); + + if (j_player_index > 0) { + return; + } + // Handle Handheld cases + auto& handheld_player = Settings::values.players.GetValue()[HANDHELD_INDEX]; + auto* handheld_controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + if (player.controller_type == Settings::ControllerType::Handheld) { + handheld_player = player; + } else { + handheld_player = {}; + } + handheld_controller->ReloadFromSettings(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_beginMapping(JNIEnv* env, + jobject j_obj, + jint jtype) { + EmulationSession::GetInstance().GetInputSubsystem().BeginMapping( + static_cast(jtype)); +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getNextInput(JNIEnv* env, + jobject j_obj) { + return Common::Android::ToJString( + env, EmulationSession::GetInstance().GetInputSubsystem().GetNextInput().Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_stopMapping(JNIEnv* env, + jobject j_obj) { + EmulationSession::GetInstance().GetInputSubsystem().StopMapping(); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_updateMappingsWithDefaultImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jstring j_device_params, + jstring j_display_name) { + auto& input_subsystem = EmulationSession::GetInstance().GetInputSubsystem(); + + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } + + // Apply new mappings + auto device = Common::ParamPackage(Common::Android::GetJString(env, j_device_params)); + auto button_mappings = input_subsystem.GetButtonMappingForDevice(device); + auto analog_mappings = input_subsystem.GetAnalogMappingForDevice(device); + auto display_name = Common::Android::GetJString(env, j_display_name); + for (const auto& button_mapping : button_mappings) { + const std::size_t index = button_mapping.first; + auto named_mapping = button_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(index, named_mapping); + }); + } + for (const auto& analog_mapping : analog_mappings) { + const std::size_t index = analog_mapping.first; + auto named_mapping = analog_mapping.second; + named_mapping.Set("display", display_name); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(index, named_mapping); + }); + } +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getButtonParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_button) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetButtonParam(j_button) + .Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setButtonParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_button_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(j_button_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jstring Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getStickParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_stick) { + return Common::Android::ToJString(env, EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetStickParam(j_stick) + .Serialize()); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setStickParamImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_stick_id, jstring j_param) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(j_stick_id, + Common::ParamPackage(Common::Android::GetJString(env, j_param))); + }); +} + +jint Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getButtonNameImpl(JNIEnv* env, + jobject j_obj, + jstring j_param) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().GetButtonName( + Common::ParamPackage(Common::Android::GetJString(env, j_param)))); +} + +jintArray Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getSupportedStyleTagsImpl( + JNIEnv* env, jobject j_obj, jint j_player_index) { + auto supported_styles = GetSupportedStyles(j_player_index); + jintArray j_supported_indexes = env->NewIntArray(supported_styles.size()); + env->SetIntArrayRegion(j_supported_indexes, 0, supported_styles.size(), + supported_styles.data()); + return j_supported_indexes; +} + +jint Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getStyleIndexImpl( + JNIEnv* env, jobject j_obj, jint j_player_index) { + return static_cast(EmulationSession::GetInstance() + .System() + .HIDCore() + .GetEmulatedControllerByIndex(j_player_index) + ->GetNpadStyleIndex(true)); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_setStyleIndexImpl( + JNIEnv* env, jobject j_obj, jint j_player_index, jint j_style_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto type = static_cast(j_style_index); + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetNpadStyleIndex(type); + }); + if (j_player_index == 0) { + auto* handheld = hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld); + auto* player_one = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1); + ConnectController(j_player_index, + player_one->IsConnected(true) || handheld->IsConnected(true)); + } +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_isControllerImpl( + JNIEnv* env, jobject j_obj, jstring jparams) { + return static_cast(EmulationSession::GetInstance().GetInputSubsystem().IsController( + Common::ParamPackage(Common::Android::GetJString(env, jparams)))); +} + +jboolean Java_org_sudachi_sudachi_1emu_features_input_NativeInput_getIsConnected( + JNIEnv* env, jobject j_obj, jint j_player_index) { + auto& hid_core = EmulationSession::GetInstance().System().HIDCore(); + auto* controller = hid_core.GetEmulatedControllerByIndex(static_cast(j_player_index)); + if (j_player_index == 0 && + controller->GetNpadStyleIndex(true) == Core::HID::NpadStyleIndex::Handheld) { + return hid_core.GetEmulatedController(Core::HID::NpadIdType::Handheld)->IsConnected(true); + } + return controller->IsConnected(true); +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_connectControllersImpl( + JNIEnv* env, jobject j_obj, jbooleanArray j_connected) { + jboolean isCopy = false; + auto j_connected_array_size = env->GetArrayLength(j_connected); + jboolean* j_connected_array = env->GetBooleanArrayElements(j_connected, &isCopy); + for (int i = 0; i < j_connected_array_size; ++i) { + ConnectController(i, j_connected_array[i]); + } +} + +void Java_org_sudachi_sudachi_1emu_features_input_NativeInput_resetControllerMappings( + JNIEnv* env, jobject j_obj, jint j_player_index) { + // Clear all previous mappings + for (int button_id = 0; button_id < Settings::NativeButton::NumButtons; ++button_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetButtonParam(button_id, {}); + }); + } + for (int analog_id = 0; analog_id < Settings::NativeAnalog::NumAnalogs; ++analog_id) { + ApplyControllerConfig(j_player_index, [&](Core::HID::EmulatedController* controller) { + controller->SetStickParam(analog_id, {}); + }); + } +} + +} // extern "C" diff --git a/src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/sudachi/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..66ebfa85c475537475e6deb531425e2b3ce3d611 GIT binary patch literal 46179 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 literal 0 HcmV?d00001 diff --git a/src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png b/src/android/sudachi/src/main/res/drawable-xhdpi/tv_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..20c770591d6c77151e620780031c978062755b23 GIT binary patch literal 7764 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 literal 0 HcmV?d00001 diff --git a/src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/sudachi/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d73fad15bd64a32b311a1b7acd1aedf28b8288ba GIT binary patch literal 56651 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 Py|1eHSfNtR?Ad<+^nLIa literal 0 HcmV?d00001 diff --git a/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml b/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml new file mode 100644 index 0000000..b078ded --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/button_l3_depressed.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/button_r3.xml b/src/android/sudachi/src/main/res/drawable/button_r3.xml new file mode 100644 index 0000000..5c6864e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/button_r3.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/dpad_standard.xml b/src/android/sudachi/src/main/res/drawable/dpad_standard.xml new file mode 100644 index 0000000..28aba65 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/dpad_standard.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_a.xml b/src/android/sudachi/src/main/res/drawable/facebutton_a.xml new file mode 100644 index 0000000..668652e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_a.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml new file mode 100644 index 0000000..4fbe069 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_a_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml new file mode 100644 index 0000000..cde7c6a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_home_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml b/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml new file mode 100644 index 0000000..4296b4f --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_minus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml new file mode 100644 index 0000000..6280278 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_minus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml b/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml new file mode 100644 index 0000000..43ae143 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_plus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml new file mode 100644 index 0000000..c510e13 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_plus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml b/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml new file mode 100644 index 0000000..fd2e442 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_screenshot_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_x.xml b/src/android/sudachi/src/main/res/drawable/facebutton_x.xml new file mode 100644 index 0000000..43fdd14 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_x.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/facebutton_y.xml b/src/android/sudachi/src/main/res/drawable/facebutton_y.xml new file mode 100644 index 0000000..980be3b --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/facebutton_y.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml b/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 0000000..3b85a3e --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml b/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..49e6ecd --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_clear.xml b/src/android/sudachi/src/main/res/drawable/ic_clear.xml new file mode 100644 index 0000000..b6edb1d --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_code.xml b/src/android/sudachi/src/main/res/drawable/ic_code.xml new file mode 100644 index 0000000..26f83b3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_code.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_controller.xml b/src/android/sudachi/src/main/res/drawable/ic_controller.xml new file mode 100644 index 0000000..060cd9a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml b/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml new file mode 100644 index 0000000..8e3c66f --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_controller_disconnected.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_delete.xml b/src/android/sudachi/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..d26a797 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_diamond.xml b/src/android/sudachi/src/main/res/drawable/ic_diamond.xml new file mode 100644 index 0000000..3896e12 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_diamond.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_exit.xml b/src/android/sudachi/src/main/res/drawable/ic_exit.xml new file mode 100644 index 0000000..a55a1d3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_export.xml b/src/android/sudachi/src/main/res/drawable/ic_export.xml new file mode 100644 index 0000000..463d2f4 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_export.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml b/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml new file mode 100644 index 0000000..7958fda --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_folder_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_github.xml b/src/android/sudachi/src/main/res/drawable/ic_github.xml new file mode 100644 index 0000000..c2ee438 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_github.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_graphics.xml b/src/android/sudachi/src/main/res/drawable/ic_graphics.xml new file mode 100644 index 0000000..2fdb5a4 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_graphics.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml b/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml new file mode 100644 index 0000000..df62dde --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_icon_bg.xml @@ -0,0 +1,751 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_install.xml b/src/android/sudachi/src/main/res/drawable/ic_install.xml new file mode 100644 index 0000000..01f2de3 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_install.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_key.xml b/src/android/sudachi/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..a394363 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_launcher.xml b/src/android/sudachi/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..af00953 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_lock.xml b/src/android/sudachi/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..ef97b19 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_nfc.xml b/src/android/sudachi/src/main/res/drawable/ic_nfc.xml new file mode 100644 index 0000000..3dacf79 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_overlay.xml b/src/android/sudachi/src/main/res/drawable/ic_overlay.xml new file mode 100644 index 0000000..c7986c5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_overlay.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_pause.xml b/src/android/sudachi/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..adb3aba --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml b/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml new file mode 100644 index 0000000..2303a46 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_pip_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_play.xml b/src/android/sudachi/src/main/res/drawable/ic_play.xml new file mode 100644 index 0000000..7f01dc5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_restore.xml b/src/android/sudachi/src/main/res/drawable/ic_restore.xml new file mode 100644 index 0000000..d6d9d40 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_restore.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_settings.xml b/src/android/sudachi/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..e527f85 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml b/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml new file mode 100644 index 0000000..b733e52 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_sudachi_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml b/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml new file mode 100644 index 0000000..7701a2b --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_system_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/ic_website.xml b/src/android/sudachi/src/main/res/drawable/ic_website.xml new file mode 100644 index 0000000..f35b84a --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/ic_website.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/sudachi/src/main/res/drawable/joystick.xml b/src/android/sudachi/src/main/res/drawable/joystick.xml new file mode 100644 index 0000000..bdd0712 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/joystick.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/joystick_range.xml b/src/android/sudachi/src/main/res/drawable/joystick_range.xml new file mode 100644 index 0000000..f6282b5 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/joystick_range.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/l_shoulder.xml b/src/android/sudachi/src/main/res/drawable/l_shoulder.xml new file mode 100644 index 0000000..28f9a99 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/l_shoulder.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml b/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml new file mode 100644 index 0000000..2f9a1fd --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/l_shoulder_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/sudachi/src/main/res/drawable/premium_background.xml b/src/android/sudachi/src/main/res/drawable/premium_background.xml new file mode 100644 index 0000000..8595e6d --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/premium_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml b/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml new file mode 100644 index 0000000..00393c0 --- /dev/null +++ b/src/android/sudachi/src/main/res/drawable/zl_trigger_depressed.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml b/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml new file mode 100644 index 0000000..90d95db --- /dev/null +++ b/src/android/sudachi/src/main/res/layout-w600dp/fragment_game_info.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/layout/card_applet_option.xml b/src/android/sudachi/src/main/res/layout/card_applet_option.xml new file mode 100644 index 0000000..19fbec9 --- /dev/null +++ b/src/android/sudachi/src/main/res/layout/card_applet_option.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/sudachi/src/main/res/layout/card_driver_option.xml b/src/android/sudachi/src/main/res/layout/card_driver_option.xml new file mode 100644 index 0000000..09e2699 --- /dev/null +++ b/src/android/sudachi/src/main/res/layout/card_driver_option.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + +