From 12efe5764a700f8811e3ea8e21e0facd941a84c4 Mon Sep 17 00:00:00 2001 From: pineappleEA Date: Wed, 31 May 2023 08:37:04 +0200 Subject: [PATCH] early-access version 3624 --- CMakeLists.txt | 67 +- CMakeModules/DownloadExternals.cmake | 7 +- LICENSES/MPL-2.0.txt | 373 ++++++ README.md | 2 +- externals/CMakeLists.txt | 6 + externals/ffmpeg/CMakeLists.txt | 70 +- src/CMakeLists.txt | 5 + src/android/.gitignore | 65 ++ src/android/app/build.gradle.kts | 245 ++++ src/android/app/proguard-rules.pro | 24 + .../app/src/ea/res/drawable/ic_yuzu.xml | 22 + .../app/src/ea/res/drawable/ic_yuzu_full.xml | 12 + .../app/src/ea/res/drawable/ic_yuzu_title.xml | 24 + src/android/app/src/main/AndroidManifest.xml | 91 ++ .../java/org/yuzu/yuzu_emu/NativeLibrary.kt | 508 +++++++++ .../java/org/yuzu/yuzu_emu/YuzuApplication.kt | 61 + .../yuzu_emu/activities/EmulationActivity.kt | 345 ++++++ .../org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 134 +++ .../yuzu_emu/adapters/HomeSettingAdapter.kt | 69 ++ .../yuzu/yuzu_emu/adapters/SetupAdapter.kt | 70 ++ .../applets/keyboard/SoftwareKeyboard.kt | 121 ++ .../keyboard/ui/KeyboardDialogFragment.kt | 100 ++ .../DiskShaderCacheProgress.kt | 48 + .../ShaderProgressViewModel.kt | 31 + .../ui/ShaderProgressDialogFragment.kt | 101 ++ .../yuzu_emu/features/DocumentProvider.kt | 300 +++++ .../settings/model/AbstractBooleanSetting.kt | 8 + .../settings/model/AbstractFloatSetting.kt | 8 + .../settings/model/AbstractIntSetting.kt | 8 + .../settings/model/AbstractSetting.kt | 12 + .../settings/model/AbstractStringSetting.kt | 8 + .../features/settings/model/BooleanSetting.kt | 38 + .../features/settings/model/FloatSetting.kt | 36 + .../features/settings/model/IntSetting.kt | 131 +++ .../features/settings/model/SettingSection.kt | 37 + .../features/settings/model/Settings.kt | 156 +++ .../settings/model/SettingsViewModel.kt | 10 + .../features/settings/model/StringSetting.kt | 37 + .../settings/model/view/DateTimeSetting.kt | 31 + .../settings/model/view/HeaderSetting.kt | 14 + .../settings/model/view/RunnableSetting.kt | 12 + .../settings/model/view/SettingsItem.kt | 39 + .../model/view/SingleChoiceSetting.kt | 40 + .../settings/model/view/SliderSetting.kt | 64 ++ .../model/view/StringSingleChoiceSetting.kt | 58 + .../settings/model/view/SubmenuSetting.kt | 15 + .../settings/model/view/SwitchSetting.kt | 62 + .../features/settings/ui/SettingsActivity.kt | 249 ++++ .../settings/ui/SettingsActivityPresenter.kt | 84 ++ .../settings/ui/SettingsActivityView.kt | 57 + .../features/settings/ui/SettingsAdapter.kt | 340 ++++++ .../features/settings/ui/SettingsFragment.kt | 122 ++ .../settings/ui/SettingsFragmentPresenter.kt | 453 ++++++++ .../settings/ui/SettingsFragmentView.kt | 58 + .../ui/viewholder/DateTimeViewHolder.kt | 48 + .../ui/viewholder/HeaderViewHolder.kt | 30 + .../ui/viewholder/RunnableViewHolder.kt | 35 + .../ui/viewholder/SettingViewHolder.kt | 36 + .../ui/viewholder/SingleChoiceViewHolder.kt | 60 + .../ui/viewholder/SliderViewHolder.kt | 39 + .../ui/viewholder/SubmenuViewHolder.kt | 35 + .../ui/viewholder/SwitchSettingViewHolder.kt | 48 + .../features/settings/utils/SettingsFile.kt | 238 ++++ .../yuzu/yuzu_emu/fragments/AboutFragment.kt | 121 ++ .../yuzu_emu/fragments/EarlyAccessFragment.kt | 83 ++ .../yuzu_emu/fragments/EmulationFragment.kt | 502 +++++++++ .../fragments/HomeSettingsFragment.kt | 291 +++++ .../PermissionDeniedDialogFragment.kt | 38 + .../fragments/ResetSettingsDialogFragment.kt | 30 + .../yuzu/yuzu_emu/fragments/SearchFragment.kt | 236 ++++ .../yuzu/yuzu_emu/fragments/SetupFragment.kt | 329 ++++++ .../fragments/SetupWarningDialogFragment.kt | 86 ++ .../layout/AutofitGridLayoutManager.kt | 61 + .../main/java/org/yuzu/yuzu_emu/model/Game.kt | 41 + .../org/yuzu/yuzu_emu/model/GamesViewModel.kt | 109 ++ .../org/yuzu/yuzu_emu/model/HomeSetting.kt | 11 + .../org/yuzu/yuzu_emu/model/HomeViewModel.kt | 36 + .../yuzu_emu/model/MinimalDocumentFile.kt | 11 + .../java/org/yuzu/yuzu_emu/model/SetupPage.kt | 19 + .../org/yuzu/yuzu_emu/overlay/InputOverlay.kt | 1002 +++++++++++++++++ .../overlay/InputOverlayDrawableButton.kt | 142 +++ .../overlay/InputOverlayDrawableDpad.kt | 267 +++++ .../overlay/InputOverlayDrawableJoystick.kt | 264 +++++ .../org/yuzu/yuzu_emu/ui/GamesFragment.kt | 165 +++ .../org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 418 +++++++ .../yuzu/yuzu_emu/ui/main/ThemeProvider.kt | 11 + .../java/org/yuzu/yuzu_emu/utils/BiMap.kt | 25 + .../yuzu_emu/utils/ControllerMappingHelper.kt | 68 ++ .../yuzu_emu/utils/DirectoryInitialization.kt | 37 + .../org/yuzu/yuzu_emu/utils/DocumentsTree.kt | 112 ++ .../yuzu_emu/utils/EmulationMenuSettings.kt | 68 ++ .../java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 298 +++++ .../yuzu/yuzu_emu/utils/ForegroundService.kt | 67 ++ .../org/yuzu/yuzu_emu/utils/GameHelper.kt | 98 ++ .../yuzu/yuzu_emu/utils/GpuDriverHelper.kt | 148 +++ .../yuzu/yuzu_emu/utils/GpuDriverMetadata.kt | 47 + .../org/yuzu/yuzu_emu/utils/InputHandler.kt | 360 ++++++ .../org/yuzu/yuzu_emu/utils/InsetsHelper.kt | 31 + .../main/java/org/yuzu/yuzu_emu/utils/Log.kt | 40 + .../java/org/yuzu/yuzu_emu/utils/NfcReader.kt | 168 +++ .../yuzu/yuzu_emu/utils/SerializableHelper.kt | 40 + .../org/yuzu/yuzu_emu/utils/ThemeHelper.kt | 97 ++ src/android/app/src/main/jni/CMakeLists.txt | 28 + .../jni/android_common/android_common.cpp | 35 + .../main/jni/android_common/android_common.h | 12 + .../main/jni/applets/software_keyboard.cpp | 277 +++++ .../src/main/jni/applets/software_keyboard.h | 78 ++ src/android/app/src/main/jni/config.cpp | 297 +++++ src/android/app/src/main/jni/config.h | 37 + src/android/app/src/main/jni/default_ini.h | 507 +++++++++ .../src/main/jni/emu_window/emu_window.cpp | 79 ++ .../app/src/main/jni/emu_window/emu_window.h | 64 ++ src/android/app/src/main/jni/id_cache.cpp | 116 ++ src/android/app/src/main/jni/id_cache.h | 19 + src/android/app/src/main/jni/native.cpp | 758 +++++++++++++ src/android/app/src/main/jni/native.h | 165 +++ .../anim_pop_settings_fragment_out.xml | 16 + .../anim-ldrtl/anim_settings_fragment_in.xml | 16 + .../anim/anim_pop_settings_fragment_out.xml | 16 + .../res/anim/anim_settings_fragment_in.xml | 16 + .../res/anim/anim_settings_fragment_out.xml | 10 + .../res/animator/menu_slide_in_from_start.xml | 20 + .../res/animator/menu_slide_out_to_start.xml | 21 + .../ic_stat_notification_logo.png | Bin 0 -> 46179 bytes .../ic_stat_notification_logo.png | Bin 0 -> 48264 bytes .../ic_stat_notification_logo.png | Bin 0 -> 56651 bytes .../src/main/res/drawable/default_icon.jpg | Bin 0 -> 6285 bytes .../src/main/res/drawable/dpad_standard.xml | 24 + .../dpad_standard_cardinal_depressed.xml | 24 + .../dpad_standard_diagonal_depressed.xml | 24 + .../src/main/res/drawable/facebutton_a.xml | 22 + .../res/drawable/facebutton_a_depressed.xml | 8 + .../src/main/res/drawable/facebutton_b.xml | 22 + .../res/drawable/facebutton_b_depressed.xml | 8 + .../src/main/res/drawable/facebutton_home.xml | 21 + .../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 + .../res/drawable/facebutton_screenshot.xml | 21 + .../facebutton_screenshot_depressed.xml | 8 + .../src/main/res/drawable/facebutton_x.xml | 22 + .../res/drawable/facebutton_x_depressed.xml | 8 + .../src/main/res/drawable/facebutton_y.xml | 22 + .../res/drawable/facebutton_y_depressed.xml | 8 + .../app/src/main/res/drawable/ic_add.xml | 9 + .../main/res/drawable/ic_arrow_forward.xml | 10 + .../app/src/main/res/drawable/ic_back.xml | 10 + .../src/main/res/drawable/ic_cartridge.xml | 12 + .../res/drawable/ic_cartridge_outline.xml | 12 + .../app/src/main/res/drawable/ic_check.xml | 9 + .../src/main/res/drawable/ic_check_circle.xml | 9 + .../app/src/main/res/drawable/ic_clear.xml | 9 + .../src/main/res/drawable/ic_controller.xml | 9 + .../app/src/main/res/drawable/ic_diamond.xml | 9 + .../app/src/main/res/drawable/ic_discord.xml | 10 + .../app/src/main/res/drawable/ic_exit.xml | 10 + .../src/main/res/drawable/ic_folder_open.xml | 9 + .../app/src/main/res/drawable/ic_github.xml | 10 + .../app/src/main/res/drawable/ic_icon_bg.xml | 751 ++++++++++++ .../src/main/res/drawable/ic_info_outline.xml | 9 + .../app/src/main/res/drawable/ic_install.xml | 9 + .../app/src/main/res/drawable/ic_key.xml | 9 + .../app/src/main/res/drawable/ic_launcher.xml | 6 + .../app/src/main/res/drawable/ic_nfc.xml | 9 + .../src/main/res/drawable/ic_notification.xml | 9 + .../app/src/main/res/drawable/ic_options.xml | 9 + .../app/src/main/res/drawable/ic_palette.xml | 9 + .../app/src/main/res/drawable/ic_pause.xml | 9 + .../app/src/main/res/drawable/ic_play.xml | 9 + .../app/src/main/res/drawable/ic_search.xml | 9 + .../app/src/main/res/drawable/ic_settings.xml | 9 + .../main/res/drawable/ic_settings_outline.xml | 9 + .../app/src/main/res/drawable/ic_unlock.xml | 9 + .../app/src/main/res/drawable/ic_website.xml | 9 + .../app/src/main/res/drawable/ic_yuzu.xml | 22 + .../src/main/res/drawable/ic_yuzu_full.xml | 12 + .../src/main/res/drawable/ic_yuzu_title.xml | 24 + .../app/src/main/res/drawable/joystick.xml | 45 + .../main/res/drawable/joystick_depressed.xml | 10 + .../src/main/res/drawable/joystick_range.xml | 38 + .../app/src/main/res/drawable/l_shoulder.xml | 23 + .../res/drawable/l_shoulder_depressed.xml | 8 + .../main/res/drawable/premium_background.xml | 9 + .../app/src/main/res/drawable/r_shoulder.xml | 23 + .../res/drawable/r_shoulder_depressed.xml | 8 + .../main/res/drawable/selector_cartridge.xml | 5 + .../main/res/drawable/selector_settings.xml | 5 + .../app/src/main/res/drawable/zl_trigger.xml | 25 + .../res/drawable/zl_trigger_depressed.xml | 10 + .../app/src/main/res/drawable/zr_trigger.xml | 25 + .../res/drawable/zr_trigger_depressed.xml | 10 + .../main/res/layout-w600dp/activity_main.xml | 58 + .../main/res/layout-w600dp/fragment_setup.xml | 40 + .../src/main/res/layout-w600dp/page_setup.xml | 65 ++ .../main/res/layout/activity_emulation.xml | 13 + .../app/src/main/res/layout/activity_main.xml | 58 + .../src/main/res/layout/activity_settings.xml | 50 + .../app/src/main/res/layout/card_game.xml | 66 ++ .../src/main/res/layout/card_home_option.xml | 60 + .../src/main/res/layout/dialog_edit_text.xml | 23 + .../main/res/layout/dialog_progress_bar.xml | 24 + .../app/src/main/res/layout/dialog_slider.xml | 37 + .../src/main/res/layout/fragment_about.xml | 199 ++++ .../main/res/layout/fragment_early_access.xml | 242 ++++ .../main/res/layout/fragment_emulation.xml | 60 + .../src/main/res/layout/fragment_games.xml | 34 + .../res/layout/fragment_home_settings.xml | 34 + .../src/main/res/layout/fragment_search.xml | 183 +++ .../src/main/res/layout/fragment_settings.xml | 14 + .../src/main/res/layout/fragment_setup.xml | 42 + .../src/main/res/layout/header_in_game.xml | 14 + .../src/main/res/layout/list_item_setting.xml | 41 + .../res/layout/list_item_setting_switch.xml | 50 + .../res/layout/list_item_settings_header.xml | 20 + .../app/src/main/res/layout/page_setup.xml | 72 ++ .../main/res/menu-w600dp/menu_navigation.xml | 19 + .../app/src/main/res/menu/menu_in_game.xml | 24 + .../app/src/main/res/menu/menu_navigation.xml | 19 + .../main/res/menu/menu_overlay_options.xml | 41 + .../app/src/main/res/menu/menu_settings.xml | 2 + .../main/res/navigation/home_navigation.xml | 50 + .../src/main/res/values-night-v31/themes.xml | 31 + .../app/src/main/res/values-night/themes.xml | 9 + .../src/main/res/values-night/yuzu_colors.xml | 37 + .../app/src/main/res/values-v31/themes.xml | 31 + .../app/src/main/res/values-w600dp/bools.xml | 4 + .../app/src/main/res/values-w600dp/dimens.xml | 5 + .../app/src/main/res/values/arrays.xml | 227 ++++ src/android/app/src/main/res/values/bools.xml | 4 + .../app/src/main/res/values/dimens.xml | 18 + .../app/src/main/res/values/integers.xml | 37 + .../app/src/main/res/values/strings.xml | 329 ++++++ .../app/src/main/res/values/styles.xml | 35 + .../app/src/main/res/values/themes.xml | 51 + .../app/src/main/res/values/yuzu_colors.xml | 37 + .../main/res/xml/data_extraction_rules.xml | 20 + .../res/xml/data_extraction_rules_api_31.xml | 43 + .../app/src/main/res/xml/nfc_tech_filter.xml | 6 + src/android/build.gradle.kts | 13 + src/android/gradle.properties | 16 + src/android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54708 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + src/android/gradlew | 175 +++ src/android/gradlew.bat | 87 ++ src/android/settings.gradle.kts | 21 + src/common/CMakeLists.txt | 13 + src/common/dynamic_library.cpp | 2 + src/common/dynamic_library.h | 3 + src/common/error.cpp | 3 +- src/common/fs/file.cpp | 38 + src/common/fs/fs_android.cpp | 98 ++ src/common/fs/fs_android.h | 62 + src/common/fs/path_util.cpp | 26 +- src/common/fs/path_util.h | 8 + src/common/host_memory.cpp | 12 +- src/common/logging/backend.cpp | 26 + src/common/logging/text_formatter.cpp | 35 + src/common/logging/text_formatter.h | 2 + src/core/CMakeLists.txt | 1 + src/core/crypto/key_manager.cpp | 8 + src/core/crypto/key_manager.h | 3 + src/core/device_memory.cpp | 8 +- src/core/frontend/emu_window.cpp | 2 - src/core/frontend/emu_window.h | 48 +- src/core/frontend/graphics_context.h | 62 + src/core/hid/emulated_console.cpp | 32 +- src/core/hid/emulated_console.h | 4 +- src/core/hid/emulated_controller.cpp | 26 + src/core/hid/emulated_controller.h | 2 + src/core/hle/kernel/k_address_space_info.cpp | 5 + src/core/hle/service/acc/profile_manager.cpp | 1 + src/input_common/drivers/virtual_amiibo.cpp | 26 +- src/input_common/drivers/virtual_amiibo.h | 2 + src/input_common/drivers/virtual_gamepad.cpp | 16 + src/input_common/drivers/virtual_gamepad.h | 12 +- .../backend/spirv/emit_spirv_atomic.cpp | 10 + .../spirv/emit_spirv_context_get_set.cpp | 44 +- .../backend/spirv/emit_spirv_warp.cpp | 17 +- .../backend/spirv/spirv_emit_context.cpp | 61 +- .../backend/spirv/spirv_emit_context.h | 16 +- src/shader_recompiler/profile.h | 3 + src/shader_recompiler/runtime_info.h | 2 + src/video_core/CMakeLists.txt | 6 +- src/video_core/engines/maxwell_3d.cpp | 12 + src/video_core/gpu.cpp | 1 + src/video_core/gpu_thread.cpp | 2 +- src/video_core/renderer_base.cpp | 1 + src/video_core/renderer_base.h | 2 +- .../renderer_null/renderer_null.cpp | 2 + .../renderer_opengl/gl_shader_context.h | 1 + .../renderer_vulkan/maxwell_to_vk.cpp | 8 + .../renderer_vulkan/renderer_vulkan.cpp | 9 +- .../renderer_vulkan/renderer_vulkan.h | 2 +- .../renderer_vulkan/vk_blit_screen.cpp | 13 + .../renderer_vulkan/vk_buffer_cache.cpp | 10 +- .../renderer_vulkan/vk_buffer_cache.h | 2 +- .../renderer_vulkan/vk_pipeline_cache.cpp | 17 +- .../renderer_vulkan/vk_present_manager.cpp | 28 +- .../renderer_vulkan/vk_present_manager.h | 10 +- .../renderer_vulkan/vk_rasterizer.cpp | 18 + .../renderer_vulkan/vk_scheduler.cpp | 14 + .../renderer_vulkan/vk_swapchain.cpp | 10 +- src/video_core/renderer_vulkan/vk_swapchain.h | 4 +- .../renderer_vulkan/vk_turbo_mode.cpp | 12 +- .../renderer_vulkan/vk_update_descriptor.h | 2 +- .../vulkan_common/vulkan_debug_callback.cpp | 28 + .../vulkan_common/vulkan_device.cpp | 103 +- src/video_core/vulkan_common/vulkan_device.h | 14 + .../vulkan_common/vulkan_library.cpp | 18 +- src/video_core/vulkan_common/vulkan_library.h | 6 +- src/yuzu/bootmanager.cpp | 1 + src/yuzu/configuration/configure_graphics.cpp | 4 +- src/yuzu/startup_checks.cpp | 4 +- src/yuzu_cmd/default_ini.h | 2 +- src/yuzu_cmd/emu_window/emu_window_sdl2.h | 2 + 317 files changed, 19676 insertions(+), 182 deletions(-) create mode 100755 LICENSES/MPL-2.0.txt create mode 100755 src/android/.gitignore create mode 100755 src/android/app/build.gradle.kts create mode 100755 src/android/app/proguard-rules.pro create mode 100755 src/android/app/src/ea/res/drawable/ic_yuzu.xml create mode 100755 src/android/app/src/ea/res/drawable/ic_yuzu_full.xml create mode 100755 src/android/app/src/ea/res/drawable/ic_yuzu_title.xml create mode 100755 src/android/app/src/main/AndroidManifest.xml create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt create mode 100755 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt create mode 100755 src/android/app/src/main/jni/CMakeLists.txt create mode 100755 src/android/app/src/main/jni/android_common/android_common.cpp create mode 100755 src/android/app/src/main/jni/android_common/android_common.h create mode 100755 src/android/app/src/main/jni/applets/software_keyboard.cpp create mode 100755 src/android/app/src/main/jni/applets/software_keyboard.h create mode 100755 src/android/app/src/main/jni/config.cpp create mode 100755 src/android/app/src/main/jni/config.h create mode 100755 src/android/app/src/main/jni/default_ini.h create mode 100755 src/android/app/src/main/jni/emu_window/emu_window.cpp create mode 100755 src/android/app/src/main/jni/emu_window/emu_window.h create mode 100755 src/android/app/src/main/jni/id_cache.cpp create mode 100755 src/android/app/src/main/jni/id_cache.h create mode 100755 src/android/app/src/main/jni/native.cpp create mode 100755 src/android/app/src/main/jni/native.h create mode 100755 src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml create mode 100755 src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml create mode 100755 src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml create mode 100755 src/android/app/src/main/res/anim/anim_settings_fragment_in.xml create mode 100755 src/android/app/src/main/res/anim/anim_settings_fragment_out.xml create mode 100755 src/android/app/src/main/res/animator/menu_slide_in_from_start.xml create mode 100755 src/android/app/src/main/res/animator/menu_slide_out_to_start.xml create mode 100755 src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png create mode 100755 src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png create mode 100755 src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png create mode 100755 src/android/app/src/main/res/drawable/default_icon.jpg create mode 100755 src/android/app/src/main/res/drawable/dpad_standard.xml create mode 100755 src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_a.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_a_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_b.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_b_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_home.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_home_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_minus.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_plus.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_screenshot.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_x.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_x_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_y.xml create mode 100755 src/android/app/src/main/res/drawable/facebutton_y_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/ic_add.xml create mode 100755 src/android/app/src/main/res/drawable/ic_arrow_forward.xml create mode 100755 src/android/app/src/main/res/drawable/ic_back.xml create mode 100755 src/android/app/src/main/res/drawable/ic_cartridge.xml create mode 100755 src/android/app/src/main/res/drawable/ic_cartridge_outline.xml create mode 100755 src/android/app/src/main/res/drawable/ic_check.xml create mode 100755 src/android/app/src/main/res/drawable/ic_check_circle.xml create mode 100755 src/android/app/src/main/res/drawable/ic_clear.xml create mode 100755 src/android/app/src/main/res/drawable/ic_controller.xml create mode 100755 src/android/app/src/main/res/drawable/ic_diamond.xml create mode 100755 src/android/app/src/main/res/drawable/ic_discord.xml create mode 100755 src/android/app/src/main/res/drawable/ic_exit.xml create mode 100755 src/android/app/src/main/res/drawable/ic_folder_open.xml create mode 100755 src/android/app/src/main/res/drawable/ic_github.xml create mode 100755 src/android/app/src/main/res/drawable/ic_icon_bg.xml create mode 100755 src/android/app/src/main/res/drawable/ic_info_outline.xml create mode 100755 src/android/app/src/main/res/drawable/ic_install.xml create mode 100755 src/android/app/src/main/res/drawable/ic_key.xml create mode 100755 src/android/app/src/main/res/drawable/ic_launcher.xml create mode 100755 src/android/app/src/main/res/drawable/ic_nfc.xml create mode 100755 src/android/app/src/main/res/drawable/ic_notification.xml create mode 100755 src/android/app/src/main/res/drawable/ic_options.xml create mode 100755 src/android/app/src/main/res/drawable/ic_palette.xml create mode 100755 src/android/app/src/main/res/drawable/ic_pause.xml create mode 100755 src/android/app/src/main/res/drawable/ic_play.xml create mode 100755 src/android/app/src/main/res/drawable/ic_search.xml create mode 100755 src/android/app/src/main/res/drawable/ic_settings.xml create mode 100755 src/android/app/src/main/res/drawable/ic_settings_outline.xml create mode 100755 src/android/app/src/main/res/drawable/ic_unlock.xml create mode 100755 src/android/app/src/main/res/drawable/ic_website.xml create mode 100755 src/android/app/src/main/res/drawable/ic_yuzu.xml create mode 100755 src/android/app/src/main/res/drawable/ic_yuzu_full.xml create mode 100755 src/android/app/src/main/res/drawable/ic_yuzu_title.xml create mode 100755 src/android/app/src/main/res/drawable/joystick.xml create mode 100755 src/android/app/src/main/res/drawable/joystick_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/joystick_range.xml create mode 100755 src/android/app/src/main/res/drawable/l_shoulder.xml create mode 100755 src/android/app/src/main/res/drawable/l_shoulder_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/premium_background.xml create mode 100755 src/android/app/src/main/res/drawable/r_shoulder.xml create mode 100755 src/android/app/src/main/res/drawable/r_shoulder_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/selector_cartridge.xml create mode 100755 src/android/app/src/main/res/drawable/selector_settings.xml create mode 100755 src/android/app/src/main/res/drawable/zl_trigger.xml create mode 100755 src/android/app/src/main/res/drawable/zl_trigger_depressed.xml create mode 100755 src/android/app/src/main/res/drawable/zr_trigger.xml create mode 100755 src/android/app/src/main/res/drawable/zr_trigger_depressed.xml create mode 100755 src/android/app/src/main/res/layout-w600dp/activity_main.xml create mode 100755 src/android/app/src/main/res/layout-w600dp/fragment_setup.xml create mode 100755 src/android/app/src/main/res/layout-w600dp/page_setup.xml create mode 100755 src/android/app/src/main/res/layout/activity_emulation.xml create mode 100755 src/android/app/src/main/res/layout/activity_main.xml create mode 100755 src/android/app/src/main/res/layout/activity_settings.xml create mode 100755 src/android/app/src/main/res/layout/card_game.xml create mode 100755 src/android/app/src/main/res/layout/card_home_option.xml create mode 100755 src/android/app/src/main/res/layout/dialog_edit_text.xml create mode 100755 src/android/app/src/main/res/layout/dialog_progress_bar.xml create mode 100755 src/android/app/src/main/res/layout/dialog_slider.xml create mode 100755 src/android/app/src/main/res/layout/fragment_about.xml create mode 100755 src/android/app/src/main/res/layout/fragment_early_access.xml create mode 100755 src/android/app/src/main/res/layout/fragment_emulation.xml create mode 100755 src/android/app/src/main/res/layout/fragment_games.xml create mode 100755 src/android/app/src/main/res/layout/fragment_home_settings.xml create mode 100755 src/android/app/src/main/res/layout/fragment_search.xml create mode 100755 src/android/app/src/main/res/layout/fragment_settings.xml create mode 100755 src/android/app/src/main/res/layout/fragment_setup.xml create mode 100755 src/android/app/src/main/res/layout/header_in_game.xml create mode 100755 src/android/app/src/main/res/layout/list_item_setting.xml create mode 100755 src/android/app/src/main/res/layout/list_item_setting_switch.xml create mode 100755 src/android/app/src/main/res/layout/list_item_settings_header.xml create mode 100755 src/android/app/src/main/res/layout/page_setup.xml create mode 100755 src/android/app/src/main/res/menu-w600dp/menu_navigation.xml create mode 100755 src/android/app/src/main/res/menu/menu_in_game.xml create mode 100755 src/android/app/src/main/res/menu/menu_navigation.xml create mode 100755 src/android/app/src/main/res/menu/menu_overlay_options.xml create mode 100755 src/android/app/src/main/res/menu/menu_settings.xml create mode 100755 src/android/app/src/main/res/navigation/home_navigation.xml create mode 100755 src/android/app/src/main/res/values-night-v31/themes.xml create mode 100755 src/android/app/src/main/res/values-night/themes.xml create mode 100755 src/android/app/src/main/res/values-night/yuzu_colors.xml create mode 100755 src/android/app/src/main/res/values-v31/themes.xml create mode 100755 src/android/app/src/main/res/values-w600dp/bools.xml create mode 100755 src/android/app/src/main/res/values-w600dp/dimens.xml create mode 100755 src/android/app/src/main/res/values/arrays.xml create mode 100755 src/android/app/src/main/res/values/bools.xml create mode 100755 src/android/app/src/main/res/values/dimens.xml create mode 100755 src/android/app/src/main/res/values/integers.xml create mode 100755 src/android/app/src/main/res/values/strings.xml create mode 100755 src/android/app/src/main/res/values/styles.xml create mode 100755 src/android/app/src/main/res/values/themes.xml create mode 100755 src/android/app/src/main/res/values/yuzu_colors.xml create mode 100755 src/android/app/src/main/res/xml/data_extraction_rules.xml create mode 100755 src/android/app/src/main/res/xml/data_extraction_rules_api_31.xml create mode 100755 src/android/app/src/main/res/xml/nfc_tech_filter.xml create mode 100755 src/android/build.gradle.kts create mode 100755 src/android/gradle.properties create mode 100755 src/android/gradle/wrapper/gradle-wrapper.jar create mode 100755 src/android/gradle/wrapper/gradle-wrapper.properties create mode 100755 src/android/gradlew create mode 100755 src/android/gradlew.bat create mode 100755 src/android/settings.gradle.kts create mode 100755 src/common/fs/fs_android.cpp create mode 100755 src/common/fs/fs_android.h create mode 100755 src/core/frontend/graphics_context.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 50a4cf08d..fe9417f37 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul include(DownloadExternals) include(CMakeDependentOption) include(CTest) +include(FetchContent) # Set bundled sdl2/qt as dependent options. # OFF by default, but if ENABLE_SDL2 and MSVC are true then ON @@ -19,7 +20,7 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON # On Linux system SDL2 is likely to be lacking HIDAPI support which have drawbacks but is needed for SDL motion CMAKE_DEPENDENT_OPTION(YUZU_USE_EXTERNAL_SDL2 "Compile external SDL2" ON "ENABLE_SDL2;NOT MSVC" OFF) -option(ENABLE_LIBUSB "Enable the use of LibUSB" ON) +option(ENABLE_LIBUSB "Enable the use of LibUSB" "NOT ${ANDROID}") option(ENABLE_OPENGL "Enable OpenGL" ON) mark_as_advanced(FORCE ENABLE_OPENGL) @@ -48,7 +49,7 @@ option(YUZU_TESTS "Compile tests" "${BUILD_TESTING}") option(YUZU_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON) -option(YUZU_ROOM "Compile LDN room server" ON) +option(YUZU_ROOM "Compile LDN room server" "NOT ${ANDROID}") CMAKE_DEPENDENT_OPTION(YUZU_CRASH_DUMPS "Compile Windows crash dump (Minidump) support" OFF "WIN32" OFF) @@ -60,7 +61,67 @@ option(YUZU_ENABLE_LTO "Enable link-time optimization" OFF) CMAKE_DEPENDENT_OPTION(YUZU_USE_FASTER_LD "Check if a faster linker is available" ON "NOT WIN32" OFF) +# On Android, fetch and compile libcxx before doing anything else +if (ANDROID) + set(CMAKE_SKIP_INSTALL_RULES ON) + set(LLVM_VERSION "15.0.6") + + # Note: even though libcxx and libcxxabi have separate releases on the project page, + # the separated releases cannot be compiled. Only in-tree builds work. Therefore we + # must fetch the source release for the entire llvm tree. + FetchContent_Declare(llvm + URL "https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/llvm-project-${LLVM_VERSION}.src.tar.xz" + URL_HASH SHA256=9d53ad04dc60cb7b30e810faf64c5ab8157dadef46c8766f67f286238256ff92 + TLS_VERIFY TRUE + ) + FetchContent_MakeAvailable(llvm) + + # libcxx has support for most of the range library, but it's gated behind a flag: + add_compile_definitions(_LIBCPP_ENABLE_EXPERIMENTAL) + + # Disable standard header inclusion + set(ANDROID_STL "none") + + # libcxxabi + set(LIBCXXABI_INCLUDE_TESTS OFF) + set(LIBCXXABI_ENABLE_SHARED FALSE) + set(LIBCXXABI_ENABLE_STATIC TRUE) + set(LIBCXXABI_LIBCXX_INCLUDES "${LIBCXX_TARGET_INCLUDE_DIRECTORY}" CACHE STRING "" FORCE) + add_subdirectory("${llvm_SOURCE_DIR}/libcxxabi" "${llvm_BINARY_DIR}/libcxxabi") + link_libraries(cxxabi_static) + + # libcxx + set(LIBCXX_ABI_NAMESPACE "__ndk1" CACHE STRING "" FORCE) + set(LIBCXX_CXX_ABI "libcxxabi") + set(LIBCXX_INCLUDE_TESTS OFF) + set(LIBCXX_INCLUDE_BENCHMARKS OFF) + set(LIBCXX_INCLUDE_DOCS OFF) + set(LIBCXX_ENABLE_SHARED FALSE) + set(LIBCXX_ENABLE_STATIC TRUE) + set(LIBCXX_ENABLE_ASSERTIONS FALSE) + add_subdirectory("${llvm_SOURCE_DIR}/libcxx" "${llvm_BINARY_DIR}/libcxx") + set_target_properties(cxx-headers PROPERTIES INTERFACE_COMPILE_OPTIONS "-isystem${CMAKE_BINARY_DIR}/${LIBCXX_INSTALL_INCLUDE_DIR}") + link_libraries(cxx_static cxx-headers) +endif() + if (YUZU_USE_BUNDLED_VCPKG) + if (ANDROID) + set(ENV{ANDROID_NDK_HOME} "${ANDROID_NDK}") + list(APPEND VCPKG_MANIFEST_FEATURES "android") + + if (CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a") + set(VCPKG_TARGET_TRIPLET "arm64-android") + # this is to avoid CMake using the host pkg-config to find the host + # libraries when building for Android targets + set(PKG_CONFIG_EXECUTABLE "aarch64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE) + elseif (CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64") + set(VCPKG_TARGET_TRIPLET "x64-android") + set(PKG_CONFIG_EXECUTABLE "x86_64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE) + else() + message(FATAL_ERROR "Unsupported Android architecture ${CMAKE_ANDROID_ARCH_ABI}") + endif() + endif() + if (YUZU_TESTS) list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests") endif() @@ -457,7 +518,7 @@ set(FFmpeg_COMPONENTS avutil swscale) -if (UNIX AND NOT APPLE) +if (UNIX AND NOT APPLE AND NOT ANDROID) find_package(PkgConfig REQUIRED) pkg_check_modules(LIBVA libva) endif() diff --git a/CMakeModules/DownloadExternals.cmake b/CMakeModules/DownloadExternals.cmake index d3dd83126..cfd1dab0f 100755 --- a/CMakeModules/DownloadExternals.cmake +++ b/CMakeModules/DownloadExternals.cmake @@ -7,6 +7,7 @@ # prefix_var: name of a variable which will be set with the path to the extracted contents function(download_bundled_external remote_path lib_name prefix_var) +set(package_base_url "https://github.com/yuzu-emu/") set(package_repo "no_platform") set(package_extension "no_platform") if (WIN32) @@ -15,10 +16,14 @@ if (WIN32) elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(package_repo "ext-linux-bin/raw/main/") set(package_extension ".tar.xz") +elseif (ANDROID) + set(package_base_url "https://gitlab.com/tertius42/") + set(package_repo "ext-android-bin/-/raw/main/") + set(package_extension ".tar.xz") else() message(FATAL_ERROR "No package available for this platform") endif() -set(package_url "https://github.com/yuzu-emu/${package_repo}") +set(package_url "${package_base_url}${package_repo}") set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}") if (NOT EXISTS "${prefix}") diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt new file mode 100755 index 000000000..14e2f777f --- /dev/null +++ b/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index a91c00b1a..321db1fe1 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ yuzu emulator early access ============= -This is the source code for early-access 3623. +This is the source code for early-access 3624. ## Legal Notice diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt index 9ad30d427..d81ea2f69 100755 --- a/externals/CMakeLists.txt +++ b/externals/CMakeLists.txt @@ -147,3 +147,9 @@ endif() add_library(stb stb/stb_dxt.cpp) target_include_directories(stb PUBLIC ./stb) + +if (ANDROID) + if (ARCHITECTURE_arm64) + add_subdirectory(libadrenotools) + endif() +endif() diff --git a/externals/ffmpeg/CMakeLists.txt b/externals/ffmpeg/CMakeLists.txt index 7e696375a..cf82222c3 100755 --- a/externals/ffmpeg/CMakeLists.txt +++ b/externals/ffmpeg/CMakeLists.txt @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2021 yuzu Emulator Project # SPDX-License-Identifier: GPL-2.0-or-later -if (NOT WIN32) +if (NOT WIN32 AND NOT ANDROID) # Build FFmpeg from externals message(STATUS "Using FFmpeg from externals") @@ -44,10 +44,12 @@ if (NOT WIN32) endforeach() find_package(PkgConfig REQUIRED) - pkg_check_modules(LIBVA libva) - pkg_check_modules(CUDA cuda) - pkg_check_modules(FFNVCODEC ffnvcodec) - pkg_check_modules(VDPAU vdpau) + if (NOT ANDROID) + pkg_check_modules(LIBVA libva) + pkg_check_modules(CUDA cuda) + pkg_check_modules(FFNVCODEC ffnvcodec) + pkg_check_modules(VDPAU vdpau) + endif() set(FFmpeg_HWACCEL_LIBRARIES) set(FFmpeg_HWACCEL_FLAGS) @@ -121,6 +123,26 @@ if (NOT WIN32) list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau) endif() + find_program(BASH_PROGRAM bash REQUIRED) + + set(FFmpeg_CROSS_COMPILE_FLAGS "") + if (ANDROID) + string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME) + set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}") + set(SYSROOT "${TOOLCHAIN}/sysroot") + set(FFmpeg_CPU "armv8-a") + list(APPEND FFmpeg_CROSS_COMPILE_FLAGS + --arch=arm64 + #--cpu=${FFmpeg_CPU} + --enable-cross-compile + --cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android- + --sysroot=${SYSROOT} + --target-os=android + --extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld" + --extra-ldflags="-nostdlib" + ) + endif() + # `configure` parameters builds only exactly what yuzu needs from FFmpeg # `--disable-vdpau` is needed to avoid linking issues set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER}) @@ -129,7 +151,7 @@ if (NOT WIN32) OUTPUT ${FFmpeg_MAKEFILE} COMMAND - /bin/bash ${FFmpeg_PREFIX}/configure + ${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure --disable-avdevice --disable-avformat --disable-doc @@ -146,12 +168,14 @@ if (NOT WIN32) --cc="${FFmpeg_CC}" --cxx="${FFmpeg_CXX}" ${FFmpeg_HWACCEL_FLAGS} + ${FFmpeg_CROSS_COMPILE_FLAGS} WORKING_DIRECTORY ${FFmpeg_BUILD_DIR} ) unset(FFmpeg_CC) unset(FFmpeg_CXX) unset(FFmpeg_HWACCEL_FLAGS) + unset(FFmpeg_CROSS_COMPILE_FLAGS) # Workaround for Ubuntu 18.04's older version of make not being able to call make as a child # with context of the jobserver. Also helps ninja users. @@ -197,7 +221,38 @@ if (NOT WIN32) else() message(FATAL_ERROR "FFmpeg not found") endif() -else(WIN32) +elseif(ANDROID) + # Use yuzu FFmpeg binaries + if (ARCHITECTURE_arm64) + set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-aarch64") + elseif (ARCHITECTURE_x86_64) + set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-x86_64") + else() + message(FATAL_ERROR "Unsupported architecture for Android FFmpeg") + endif() + set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") + download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "") + set(FFmpeg_FOUND YES) + set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) + set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE) + set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) + set(FFmpeg_LIBRARIES + ${FFmpeg_LIBRARY_DIR}/libavcodec.so + ${FFmpeg_LIBRARY_DIR}/libavdevice.so + ${FFmpeg_LIBRARY_DIR}/libavfilter.so + ${FFmpeg_LIBRARY_DIR}/libavformat.so + ${FFmpeg_LIBRARY_DIR}/libavutil.so + ${FFmpeg_LIBRARY_DIR}/libswresample.so + ${FFmpeg_LIBRARY_DIR}/libswscale.so + ${FFmpeg_LIBRARY_DIR}/libvpx.a + ${FFmpeg_LIBRARY_DIR}/libx264.a + CACHE PATH "Paths to FFmpeg libraries" FORCE) + # exported variables + set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE) + set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE) + set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE) + set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE) +elseif(WIN32) # Use yuzu FFmpeg binaries set(FFmpeg_EXT_NAME "ffmpeg-5.1.3") set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") @@ -206,7 +261,6 @@ else(WIN32) set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE) set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) - set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE) set(FFmpeg_LIBRARIES ${FFmpeg_LIBRARY_DIR}/swscale.lib ${FFmpeg_LIBRARY_DIR}/avcodec.lib diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f0e573327..0305f2baa 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -195,3 +195,8 @@ endif() if (ENABLE_WEB_SERVICE) add_subdirectory(web_service) endif() + +if (ANDROID) + add_subdirectory(android/app/src/main/jni) + target_include_directories(yuzu-android PRIVATE android/app/src/main) +endif() diff --git a/src/android/.gitignore b/src/android/.gitignore new file mode 100755 index 000000000..121cc8484 --- /dev/null +++ b/src/android/.gitignore @@ -0,0 +1,65 @@ +# SPDX-FileCopyrightText: 2023 yuzu Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +# Uncomment the following line if you do not want to check your keystore files in. +#*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# CXX compile cache +app/.cxx + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts new file mode 100755 index 000000000..ec5f78746 --- /dev/null +++ b/src/android/app/build.gradle.kts @@ -0,0 +1,245 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-parcelize") + kotlin("plugin.serialization") version "1.8.21" +} + +/** + * Use the number of seconds/10 since Jan 1 2016 as the versionCode. + * This lets us upload a new build at most every 10 seconds for the + * next 680 years. + */ +val autoVersion = (((System.currentTimeMillis() / 1000) - 1451606400) / 10).toInt() + +@Suppress("UnstableApiUsage") +android { + namespace = "org.yuzu.yuzu_emu" + + compileSdkVersion = "android-33" + ndkVersion = "25.2.9519653" + + buildFeatures { + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packagingOptions { + // This is necessary for libadrenotools custom driver loading + jniLibs.useLegacyPackaging = true + } + + lint { + // This is important as it will run lint but not abort on error + // Lint has some overly obnoxious "errors" that should really be warnings + abortOnError = false + + //Uncomment disable lines for test builds... + //disable 'MissingTranslation'bin + //disable 'ExtraTranslation' + } + + defaultConfig { + // TODO If this is ever modified, change application_id in strings.xml + applicationId = "org.yuzu.yuzu_emu" + minSdk = 30 + targetSdk = 33 + versionName = getGitVersion() + + ndk { + abiFilters += listOf("arm64-v8a") + } + + buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") + buildConfigField("String", "BRANCH", "\"${getBranch()}\"") + } + + // Define build types, which are orthogonal to product flavors. + buildTypes { + + // Signed by release key, allowing for upload to Play Store. + release { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + isDebuggable = false + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) + } + + register("relWithVersionCode") { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + isDebuggable = false + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) + } + + // builds a release build that doesn't need signing + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + register("relWithDebInfo") { + signingConfig = signingConfigs.getByName("debug") + isMinifyEnabled = true + isDebuggable = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) + versionNameSuffix = "-debug" + isJniDebuggable = true + } + + // Signed by debug key disallowing distribution on Play Store. + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + debug { + isDebuggable = true + isJniDebuggable = true + versionNameSuffix = "-debug" + } + } + + flavorDimensions.add("version") + productFlavors { + create("mainline") { + dimension = "version" + buildConfigField("Boolean", "PREMIUM", "false") + } + + create("ea") { + dimension = "version" + buildConfigField("Boolean", "PREMIUM", "true") + applicationIdSuffix = ".ea" + } + } + + externalNativeBuild { + cmake { + version = "3.22.1" + path = file("../../../CMakeLists.txt") + } + } + + defaultConfig { + externalNativeBuild { + cmake { + arguments( + "-DENABLE_QT=0", // Don't use QT + "-DENABLE_SDL2=0", // Don't use SDL + "-DENABLE_WEB_SERVICE=0", // Don't use telemetry + "-DBUNDLE_SPEEX=ON", + "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work + "-DYUZU_USE_BUNDLED_VCPKG=ON", + "-DYUZU_USE_BUNDLED_FFMPEG=ON", + "-DYUZU_ENABLE_LTO=ON" + ) + + abiFilters("arm64-v8a", "x86_64") + } + } + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.recyclerview:recyclerview:1.3.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.fragment:fragment-ktx:1.5.7") + implementation("androidx.documentfile:documentfile:1.0.1") + implementation("com.google.android.material:material:1.9.0") + implementation("androidx.preference:preference:1.2.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + implementation("io.coil-kt:coil:2.2.2") + implementation("androidx.core:core-splashscreen:1.0.1") + implementation("androidx.window:window:1.0.0") + implementation("org.ini4j:ini4j:0.5.4") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") + implementation("androidx.navigation:navigation-fragment-ktx:2.5.3") + implementation("androidx.navigation:navigation-ui-ktx:2.5.3") + implementation("info.debatty:java-string-similarity:2.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") +} + +fun getGitVersion(): String { + var versionName = "0.0" + + try { + versionName = ProcessBuilder("git", "describe", "--always", "--long") + .directory(project.rootDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start().inputStream.bufferedReader().use { it.readText() } + .trim() + .replace(Regex("(-0)?-[^-]+$"), "") + } catch (e: Exception) { + logger.error("Cannot find git, defaulting to dummy version number") + } + + if (System.getenv("GITHUB_ACTIONS") != null) { + val gitTag = System.getenv("GIT_TAG_NAME") + versionName = gitTag ?: versionName + } + + return versionName +} + +fun getGitHash(): String { + try { + val processBuilder = ProcessBuilder("git", "rev-parse", "--short", "HEAD") + processBuilder.directory(project.rootDir) + val process = processBuilder.start() + val inputStream = process.inputStream + val errorStream = process.errorStream + process.waitFor() + + return if (process.exitValue() == 0) { + inputStream.bufferedReader() + .use { it.readText().trim() } // return the value of gitHash + } else { + val errorMessage = errorStream.bufferedReader().use { it.readText().trim() } + logger.error("Error running git command: $errorMessage") + "dummy-hash" // return a dummy hash value in case of an error + } + } catch (e: Exception) { + logger.error("$e: Cannot find git, defaulting to dummy build hash") + return "dummy-hash" // return a dummy hash value in case of an error + } +} + +fun getBranch(): String { + try { + val processBuilder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD") + processBuilder.directory(project.rootDir) + val process = processBuilder.start() + val inputStream = process.inputStream + val errorStream = process.errorStream + process.waitFor() + + return if (process.exitValue() == 0) { + inputStream.bufferedReader() + .use { it.readText().trim() } // return the value of gitHash + } else { + val errorMessage = errorStream.bufferedReader().use { it.readText().trim() } + logger.error("Error running git command: $errorMessage") + "dummy-hash" // return a dummy hash value in case of an error + } + } catch (e: Exception) { + logger.error("$e: Cannot find git, defaulting to dummy build hash") + return "dummy-hash" // return a dummy hash value in case of an error + } +} diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro new file mode 100755 index 000000000..691e08fd0 --- /dev/null +++ b/src/android/app/proguard-rules.pro @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2023 yuzu 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/app/src/ea/res/drawable/ic_yuzu.xml b/src/android/app/src/ea/res/drawable/ic_yuzu.xml new file mode 100755 index 000000000..deb8ba53f --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_yuzu.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml new file mode 100755 index 000000000..4ef472876 --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml new file mode 100755 index 000000000..29d0cfced --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml new file mode 100755 index 000000000..43087f2c0 --- /dev/null +++ b/src/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt new file mode 100755 index 000000000..c11b6bc16 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.Surface +import android.view.View +import android.widget.TextView +import androidx.annotation.Keep +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath +import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize +import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri +import org.yuzu.yuzu_emu.utils.Log.error +import org.yuzu.yuzu_emu.utils.Log.verbose +import org.yuzu.yuzu_emu.utils.Log.warning +import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable +import java.lang.ref.WeakReference + +/** + * Class which contains methods that interact + * with the native side of the Yuzu code. + */ +object NativeLibrary { + /** + * 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 + + /** + * Controller type for each device + */ + const val ProController = 3 + const val Handheld = 4 + const val JoyconDual = 5 + const val JoyconLeft = 6 + const val JoyconRight = 7 + const val GameCube = 8 + const val Pokeball = 9 + const val NES = 10 + const val SNES = 11 + const val N64 = 12 + const val SegaGenesis = 13 + + @JvmField + var sEmulationActivity = WeakReference(null) + + init { + try { + System.loadLibrary("yuzu-android") + } catch (ex: UnsatisfiedLinkError) { + error("[NativeLibrary] $ex") + } + } + + @Keep + @JvmStatic + fun openContentUri(path: String?, openmode: String?): Int { + return if (isNativePath(path!!)) { + YuzuApplication.documentsTree!!.openContentUri(path, openmode) + } else openContentUri(appContext, path, openmode) + } + + @Keep + @JvmStatic + fun getSize(path: String?): Long { + return if (isNativePath(path!!)) { + YuzuApplication.documentsTree!!.getFileSize(path) + } else getFileSize(appContext, path) + } + + /** + * Returns true if pro controller isn't available and handheld is + */ + external fun isHandheldOnly(): Boolean + + /** + * Changes controller type for a specific device. + * + * @param Device The input descriptor of the gamepad. + * @param Type The NpadStyleIndex of the gamepad. + */ + external fun setDeviceType(Device: Int, Type: Int): Boolean + + /** + * Handles event when a gamepad is connected. + * + * @param Device The input descriptor of the gamepad. + */ + external fun onGamePadConnectEvent(Device: Int): Boolean + + /** + * Handles event when a gamepad is disconnected. + * + * @param Device The input descriptor of the gamepad. + */ + external fun onGamePadDisconnectEvent(Device: Int): Boolean + + /** + * Handles button press events for a gamepad. + * + * @param Device The input descriptor of the gamepad. + * @param Button Key code identifying which button was pressed. + * @param Action Mask identifying which action is happening (button pressed down, or button released). + * @return If we handled the button press. + */ + external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean + + /** + * Handles joystick movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis The axis ID + * @param x_axis The value of the x-axis represented by the given ID. + * @param y_axis The value of the y-axis represented by the given ID. + */ + external fun onGamePadJoystickEvent( + Device: Int, + Axis: Int, + x_axis: Float, + y_axis: Float + ): Boolean + + /** + * Handles motion events. + * + * @param delta_timestamp The finger id corresponding to this event + * @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor. + * @param accel_x,accel_y,accel_z The value of the y-axis + */ + external fun onGamePadMotionEvent( + Device: Int, + delta_timestamp: Long, + gyro_x: Float, + gyro_y: Float, + gyro_z: Float, + accel_x: Float, + accel_y: Float, + accel_z: Float + ): Boolean + + /** + * Signals and load a nfc tag + * + * @param data Byte array containing all the data from a nfc tag + */ + external fun onReadNfcTag(data: ByteArray?): Boolean + + /** + * Removes current loaded nfc tag + */ + external fun onRemoveNfcTag(): Boolean + + /** + * Handles touch press events. + * + * @param finger_id The finger id corresponding to this event + * @param x_axis The value of the x-axis. + * @param y_axis The value of the y-axis. + */ + external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float) + + /** + * Handles touch movement. + * + * @param x_axis The value of the instantaneous x-axis. + * @param y_axis The value of the instantaneous y-axis. + */ + external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float) + + /** + * Handles touch release events. + * + * @param finger_id The finger id corresponding to this event + */ + external fun onTouchReleased(finger_id: Int) + + external fun reloadSettings() + + external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String? + + external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?) + + external fun initGameIni(gameID: String?) + + /** + * Gets the embedded icon within the given ROM. + * + * @param filename the file path to the ROM. + * @return a byte array containing the JPEG data for the icon. + */ + external fun getIcon(filename: String): ByteArray + + /** + * Gets the embedded title of the given ISO/ROM. + * + * @param filename The file path to the ISO/ROM. + * @return the embedded title of the ISO/ROM. + */ + external fun getTitle(filename: String): String + + external fun getDescription(filename: String): String + + external fun getGameId(filename: String): String + + external fun getRegions(filename: String): String + + external fun getCompany(filename: String): String + + external fun setAppDirectory(directory: String) + + external fun initializeGpuDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String? + ) + + external fun reloadKeys(): Boolean + + external fun initializeEmulation() + + external fun defaultCPUCore(): Int + + /** + * Begins emulation. + */ + external fun run(path: String?) + + /** + * Begins emulation from the specified savestate. + */ + external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean) + + // Surface Handling + external fun surfaceChanged(surf: Surface?) + + external fun surfaceDestroyed() + + /** + * Unpauses emulation from a paused state. + */ + external fun unPauseEmulation() + + /** + * Pauses emulation. + */ + external fun pauseEmulation() + + /** + * Stops emulation. + */ + external fun stopEmulation() + + /** + * Resets the in-memory ROM metadata cache. + */ + external fun resetRomMetadata() + + /** + * Returns true if emulation is running (or is paused). + */ + external fun isRunning(): Boolean + + /** + * Returns the performance stats for the current game + */ + external fun getPerfStats(): DoubleArray + + /** + * Notifies the core emulation that the orientation has changed. + */ + external fun notifyOrientationChange(layout_option: Int, rotation: Int) + + enum class CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown + } + + private var coreErrorAlertResult = false + private val coreErrorAlertLock = Object() + + class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val title = requireArguments().serializable("title") + val message = requireArguments().serializable("message") + + return MaterialAlertDialogBuilder(requireActivity()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button, null) + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + coreErrorAlertResult = false + synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + coreErrorAlertResult = true + synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } + } + + companion object { + 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 + } + } + } + + private fun onCoreErrorImpl(title: String, message: String) { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + error("[NativeLibrary] EmulationActivity not present") + return + } + + val fragment = CoreErrorDialogFragment.newInstance(title, message) + fragment.show(emulationActivity.supportFragmentManager, "coreError") + } + + /** + * Handles a core error. + * + * @return true: continue; false: abort + */ + fun onCoreError(error: CoreError?, details: String): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + error("[NativeLibrary] EmulationActivity not present") + return false + } + + val title: String + val message: String + when (error) { + CoreError.ErrorSystemFiles -> { + title = emulationActivity.getString(R.string.system_archive_not_found) + message = emulationActivity.getString( + R.string.system_archive_not_found_message, + details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) } + ) + } + CoreError.ErrorSavestate -> { + title = emulationActivity.getString(R.string.save_load_error) + message = details + } + CoreError.ErrorUnknown -> { + title = emulationActivity.getString(R.string.fatal_error) + message = emulationActivity.getString(R.string.fatal_error_message) + } + else -> { + return true + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) }) + + // Wait for the lock to notify that it is complete. + synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } + + return coreErrorAlertResult + } + + @Keep + @JvmStatic + fun exitEmulationActivity(resultCode: Int) { + val Success = 0 + val ErrorNotInitialized = 1 + val ErrorGetLoader = 2 + val ErrorSystemFiles = 3 + val ErrorSharedFont = 4 + val ErrorVideoCore = 5 + val ErrorUnknown = 6 + val ErrorLoader = 7 + + val captionId: Int + var descriptionId: Int + when (resultCode) { + ErrorVideoCore -> { + captionId = R.string.loader_error_video_core + descriptionId = R.string.loader_error_video_core_description + } + else -> { + captionId = R.string.loader_error_encrypted + descriptionId = R.string.loader_error_encrypted_roms_description + if (!reloadKeys()) { + descriptionId = R.string.loader_error_encrypted_keys_description + } + } + } + + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + warning("[NativeLibrary] EmulationActivity is null, can't exit.") + return + } + + val builder = MaterialAlertDialogBuilder(emulationActivity) + .setTitle(captionId) + .setMessage( + Html.fromHtml( + emulationActivity.getString(descriptionId), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() } + .setOnDismissListener { emulationActivity.finish() } + emulationActivity.runOnUiThread { + val alert = builder.create() + alert.show() + (alert.findViewById(android.R.id.message) as TextView).movementMethod = + LinkMovementMethod.getInstance() + } + } + + fun setEmulationActivity(emulationActivity: EmulationActivity?) { + verbose("[NativeLibrary] Registering EmulationActivity.") + sEmulationActivity = WeakReference(emulationActivity) + } + + fun clearEmulationActivity() { + verbose("[NativeLibrary] Unregistering EmulationActivity.") + sEmulationActivity.clear() + } + + /** + * Logs the Yuzu version, Android version and, CPU. + */ + external fun logDeviceInfo() + + /** + * Submits inline keyboard text. Called on input for buttons that result text. + * @param text Text to submit to the inline software keyboard implementation. + */ + external fun submitInlineKeyboardText(text: String?) + + /** + * Submits inline keyboard input. Used to indicate keys pressed that are not text. + * @param key_code Android Key Code associated with the keyboard input. + */ + external fun submitInlineKeyboardInput(key_code: Int) + + /** + * Button type for use in onTouchEvent + */ + object ButtonType { + const val BUTTON_A = 0 + const val BUTTON_B = 1 + const val BUTTON_X = 2 + const val BUTTON_Y = 3 + const val STICK_L = 4 + const val STICK_R = 5 + const val TRIGGER_L = 6 + const val TRIGGER_R = 7 + const val TRIGGER_ZL = 8 + const val TRIGGER_ZR = 9 + const val BUTTON_PLUS = 10 + const val BUTTON_MINUS = 11 + const val DPAD_LEFT = 12 + const val DPAD_UP = 13 + const val DPAD_RIGHT = 14 + const val DPAD_DOWN = 15 + const val BUTTON_SL = 16 + const val BUTTON_SR = 17 + const val BUTTON_HOME = 18 + const val BUTTON_CAPTURE = 19 + } + + /** + * Stick type for use in onTouchEvent + */ + object StickType { + const val STICK_L = 0 + const val STICK_R = 1 + } + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt new file mode 100755 index 000000000..4c947b786 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.DocumentsTree +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import java.io.File + +fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir + +class YuzuApplication : Application() { + private fun createNotificationChannels() { + val emulationChannel = NotificationChannel( + getString(R.string.emulation_notification_channel_id), + getString(R.string.emulation_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + emulationChannel.description = getString(R.string.emulation_notification_channel_description) + emulationChannel.setSound(null, null) + emulationChannel.vibrationPattern = null + + 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(emulationChannel) + notificationManager.createNotificationChannel(noticeChannel) + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + DirectoryInitialization.start(applicationContext) + GpuDriverHelper.initializeDriverParameters(applicationContext) + NativeLibrary.logDeviceInfo() + + createNotificationChannels(); + } + + companion object { + var documentsTree: DocumentsTree? = null + lateinit var application: YuzuApplication + + val appContext: Context + get() = application.applicationContext + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt new file mode 100755 index 000000000..37caa1b83 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -0,0 +1,345 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.activities + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Rect +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.WindowManager +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider.OnChangeListener +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogSliderBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.fragments.EmulationFragment +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.ControllerMappingHelper +import org.yuzu.yuzu_emu.utils.ForegroundService +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.NfcReader +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable +import org.yuzu.yuzu_emu.utils.ThemeHelper +import kotlin.math.roundToInt + +class EmulationActivity : AppCompatActivity(), SensorEventListener { + private var controllerMappingHelper: ControllerMappingHelper? = null + + var isActivityRecreated = false + private var menuVisible = false + private var emulationFragment: EmulationFragment? = null + private lateinit var nfcReader: NfcReader + private lateinit var inputHandler: InputHandler + + private val gyro = FloatArray(3) + private val accel = FloatArray(3) + private var motionTimestamp: Long = 0 + private var flipMotionOrientation: Boolean = false + + private lateinit var game: Game + + override fun onDestroy() { + stopForegroundService(this) + super.onDestroy() + } + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + // Get params we were passed + game = intent.parcelable(EXTRA_SELECTED_GAME)!! + isActivityRecreated = false + } else { + isActivityRecreated = true + restoreState(savedInstanceState) + } + controllerMappingHelper = ControllerMappingHelper() + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + setContentView(R.layout.activity_emulation) + window.decorView.setBackgroundColor(getColor(android.R.color.black)) + + // Find or create the EmulationFragment + emulationFragment = + supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment? + if (emulationFragment == null) { + emulationFragment = EmulationFragment.newInstance(game) + supportFragmentManager.beginTransaction() + .add(R.id.frame_emulation_fragment, emulationFragment!!) + .commit() + } + title = game.title + + nfcReader = NfcReader(this) + nfcReader.initialize() + + inputHandler = InputHandler() + inputHandler.initialize() + + // Start a foreground service to prevent the app from getting killed in the background + val startIntent = Intent(this, ForegroundService::class.java) + startForegroundService(startIntent) + } + + 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() + } + + override fun onPause() { + super.onPause() + nfcReader.stopScanning() + stopMotionSensorListener() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + nfcReader.onNewIntent(intent) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelable(EXTRA_SELECTED_GAME, game) + super.onSaveInstanceState(outState) + } + + 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) + } + + 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) + } + + // 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 + NativeLibrary.onGamePadMotionEvent( + NativeLibrary.Player1Device, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + NativeLibrary.onGamePadMotionEvent( + NativeLibrary.ConsoleDevice, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + } + + override fun onAccuracyChanged(sensor: Sensor, i: Int) {} + + private fun restoreState(savedInstanceState: Bundle) { + game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!! + } + + private fun enableFullscreenImmersive() { + window.attributes.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + + // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar. + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_IMMERSIVE + } + + private fun editControlsPlacement() { + if (emulationFragment!!.isConfiguringControls) { + emulationFragment!!.stopConfiguringControls() + } else { + emulationFragment!!.startConfiguringControls() + } + } + + private fun adjustScale() { + val sliderBinding = DialogSliderBinding.inflate(layoutInflater) + sliderBinding.slider.valueTo = 150F + sliderBinding.slider.value = + PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat() + sliderBinding.slider.addOnChangeListener(OnChangeListener { _, value, _ -> + sliderBinding.textValue.text = value.toString() + setControlScale(value.toInt()) + }) + sliderBinding.textValue.text = sliderBinding.slider.value.toString() + sliderBinding.textUnits.text = "%" + MaterialAlertDialogBuilder(this) + .setTitle(R.string.emulation_control_scale) + .setView(sliderBinding.root) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + setControlScale(sliderBinding.slider.value.toInt()) + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + } + .show() + } + + 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) + } + + private fun setControlScale(scale: Int) { + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() + .putInt(Settings.PREF_CONTROL_SCALE, scale) + .apply() + emulationFragment!!.refreshInputOverlay() + } + + private fun resetOverlay() { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.emulation_touch_overlay_reset)) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationFragment!!.resetInputOverlay() } + .setNegativeButton(android.R.string.cancel, null) + .create() + .show() + } + + 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) + } + + fun stopForegroundService(activity: Activity) { + val startIntent = Intent(activity, ForegroundService::class.java) + startIntent.action = ForegroundService.ACTION_STOP + activity.startForegroundService(startIntent) + } + + 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/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt new file mode 100755 index 000000000..7f9e2e2d4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil.load +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.CardGameBinding +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder +import org.yuzu.yuzu_emu.model.GamesViewModel + +class GameAdapter(private val activity: AppCompatActivity) : + ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + // Create a new view. + val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.cardGame.setOnClickListener(this) + + // Use that view to create a ViewHolder. + return GameViewHolder(binding) + } + + override fun onBindViewHolder(holder: GameViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + override fun getItemCount(): Int = currentList.size + + /** + * Launches the game that was clicked on. + * + * @param view The card representing the game the user wants to play. + */ + override fun onClick(view: View) { + val holder = view.tag as GameViewHolder + + val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true + if (!gameExists) { + Toast.makeText( + YuzuApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + return + } + + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.edit() + .putLong( + holder.game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + EmulationActivity.launch(activity, holder.game) + } + + inner class GameViewHolder(val binding: CardGameBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var game: Game + + init { + binding.cardGame.tag = this + } + + fun bind(game: Game) { + this.game = game + + binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + activity.lifecycleScope.launch { + val bitmap = decodeGameIcon(game.path) + binding.imageGameScreen.load(bitmap) { + error(R.drawable.default_icon) + } + } + + binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + binding.textGameTitle.postDelayed( + { + binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.textGameTitle.isSelected = true + }, + 3000 + ) + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem.gameId == newItem.gameId + } + + override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem == newItem + } + } + + private fun decodeGameIcon(uri: String): Bitmap? { + val data = NativeLibrary.getIcon(uri) + return BitmapFactory.decodeByteArray( + data, + 0, + data.size, + BitmapFactory.Options() + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt new file mode 100755 index 000000000..b719dd539 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding +import org.yuzu.yuzu_emu.model.HomeSetting + +class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List) : + RecyclerView.Adapter(), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { + val binding = + CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.root.setOnClickListener(this) + return HomeOptionViewHolder(binding) + } + + override fun getItemCount(): Int { + return options.size + } + + override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) { + holder.bind(options[position]) + } + + override fun onClick(view: View) { + val holder = view.tag as HomeOptionViewHolder + holder.option.onClick.invoke() + } + + inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var option: HomeSetting + + init { + itemView.tag = this + } + + fun bind(option: HomeSetting) { + this.option = option + binding.optionTitle.text = activity.resources.getString(option.titleId) + binding.optionDescription.text = activity.resources.getString(option.descriptionId) + binding.optionIcon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + option.iconId, + activity.theme + ) + ) + + when (option.titleId) { + R.string.get_early_access -> binding.optionLayout.background = + ContextCompat.getDrawable( + binding.optionCard.context, + R.drawable.premium_background + ) + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt new file mode 100755 index 000000000..481ddd5a5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import org.yuzu.yuzu_emu.databinding.PageSetupBinding +import org.yuzu.yuzu_emu.model.SetupPage + +class SetupAdapter(val activity: AppCompatActivity, val pages: List) : + RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { + val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return SetupPageViewHolder(binding) + } + + override fun getItemCount(): Int = pages.size + + override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) = + holder.bind(pages[position]) + + inner class SetupPageViewHolder(val binding: PageSetupBinding) : + RecyclerView.ViewHolder(binding.root) { + lateinit var page: SetupPage + + init { + itemView.tag = this + } + + fun bind(page: SetupPage) { + this.page = page + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + page.iconId, + activity.theme + ) + ) + binding.textTitle.text = activity.resources.getString(page.titleId) + binding.textDescription.text = + Html.fromHtml(activity.resources.getString(page.descriptionId), 0) + + binding.buttonAction.apply { + text = activity.resources.getString(page.buttonTextId) + if (page.buttonIconId != 0) { + icon = ResourcesCompat.getDrawable( + activity.resources, + page.buttonIconId, + activity.theme + ) + } + iconGravity = + if (page.leftAlignedIcon) { + MaterialButton.ICON_GRAVITY_START + } else { + MaterialButton.ICON_GRAVITY_END + } + setOnClickListener { + page.buttonAction.invoke() + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt new file mode 100755 index 000000000..82a6712b6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.View +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.annotation.Keep +import androidx.core.view.ViewCompat +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment +import java.io.Serializable + +@Keep +object SoftwareKeyboard { + lateinit var data: KeyboardData + val dataLock = Object() + + private fun executeNormalImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = KeyboardData(SwkbdResult.Cancel.ordinal, "") + val fragment = KeyboardDialogFragment.newInstance(config) + fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) + } + + private fun executeInlineImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + + val overlayView = emulationActivity!!.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) + + // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. + val handler = Handler(Looper.myLooper()!!) + val delayMs = 500 + handler.postDelayed(object : Runnable { + override fun run() { + val insets = ViewCompat.getRootWindowInsets(overlayView) + val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) + if (isKeyboardVisible) { + handler.postDelayed(this, delayMs.toLong()) + return + } + + // No longer visible, submit the result. + NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) + } + }, delayMs.toLong()) + } + + @JvmStatic + fun executeNormal(config: KeyboardConfig): KeyboardData { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } + synchronized(dataLock) { + dataLock.wait() + } + return data + } + + @JvmStatic + fun executeInline(config: KeyboardConfig) { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } + } + + // Corresponds to Service::AM::Applets::SwkbdType + enum class SwkbdType { + Normal, + NumberPad, + Qwerty, + Unknown3, + Latin, + SimplifiedChinese, + TraditionalChinese, + Korean + } + + // Corresponds to Service::AM::Applets::SwkbdPasswordMode + enum class SwkbdPasswordMode { + Disabled, + Enabled + } + + // Corresponds to Service::AM::Applets::SwkbdResult + enum class SwkbdResult { + Ok, + Cancel + } + + @Keep + data class KeyboardConfig( + var ok_text: String? = null, + var header_text: String? = null, + var sub_text: String? = null, + var guide_text: String? = null, + var initial_text: String? = null, + var left_optional_symbol_key: Short = 0, + var right_optional_symbol_key: Short = 0, + var max_text_length: Int = 0, + var min_text_length: Int = 0, + var initial_cursor_position: Int = 0, + var type: Int = 0, + var password_mode: Int = 0, + var text_draw_type: Int = 0, + var key_disable_flags: Int = 0, + var use_blur_background: Boolean = false, + var enable_backspace_button: Boolean = false, + var enable_return_button: Boolean = false, + var disable_cancel_button: Boolean = false + ) : Serializable + + // Corresponds to Frontend::KeyboardData + @Keep + data class KeyboardData(var result: Int, var text: String) +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt new file mode 100755 index 000000000..4b12809bc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable + +class KeyboardDialogFragment : DialogFragment() { + private lateinit var binding: DialogEditTextBinding + private lateinit var config: KeyboardConfig + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + config = requireArguments().serializable(CONFIG)!! + + // Set up the input + binding.editText.hint = config.initial_text + binding.editText.isSingleLine = !config.enable_return_button + binding.editText.filters = + arrayOf(InputFilter.LengthFilter(config.max_text_length)) + + // Handle input type + var inputType: Int + when (config.type) { + SoftwareKeyboard.SwkbdType.Normal.ordinal, + SoftwareKeyboard.SwkbdType.Qwerty.ordinal, + SoftwareKeyboard.SwkbdType.Unknown3.ordinal, + SoftwareKeyboard.SwkbdType.Latin.ordinal, + SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, + SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, + SoftwareKeyboard.SwkbdType.Korean.ordinal -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { + inputType = InputType.TYPE_CLASS_NUMBER + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD + } + } + else -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + } + binding.editText.inputType = inputType + + val headerText = + config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } + val okText = + config.ok_text!!.ifEmpty { resources.getString(android.R.string.ok) } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(headerText) + .setView(binding.root) + .setPositiveButton(okText) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal + SoftwareKeyboard.data.text = binding.editText.text.toString() + } + .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + synchronized(SoftwareKeyboard.dataLock) { + SoftwareKeyboard.dataLock.notifyAll() + } + } + + companion object { + const val TAG = "KeyboardDialogFragment" + const val CONFIG = "keyboard_config" + + fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { + val frag = KeyboardDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100755 index 000000000..3b1559c80 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache + +import androidx.annotation.Keep +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment + +@Keep +object DiskShaderCacheProgress { + val finishLock = Object() + private lateinit var fragment: ShaderProgressDialogFragment + + private fun prepareDialog() { + val emulationActivity = NativeLibrary.sEmulationActivity.get()!! + emulationActivity.runOnUiThread { + fragment = ShaderProgressDialogFragment.newInstance( + emulationActivity.getString(R.string.loading), + emulationActivity.getString(R.string.preparing_shaders) + ) + fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG) + } + synchronized(finishLock) { finishLock.wait() } + } + + @JvmStatic + fun loadProgress(stage: Int, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + ?: error("[DiskShaderCacheProgress] EmulationActivity not present") + + when (LoadCallbackStage.values()[stage]) { + LoadCallbackStage.Prepare -> prepareDialog() + LoadCallbackStage.Build -> fragment.onUpdateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + LoadCallbackStage.Complete -> fragment.dismiss() + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, Build, Complete + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt new file mode 100755 index 000000000..bf6f0366d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class ShaderProgressViewModel : ViewModel() { + private val _progress = MutableLiveData(0) + val progress: LiveData get() = _progress + + private val _max = MutableLiveData(0) + val max: LiveData get() = _max + + private val _message = MutableLiveData("") + val message: LiveData get() = _message + + fun setProgress(progress: Int) { + _progress.postValue(progress) + } + + fun setMax(max: Int) { + _max.postValue(max) + } + + fun setMessage(msg: String) { + _message.postValue(msg) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt new file mode 100755 index 000000000..2c68c9ac3 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress +import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel + +class ShaderProgressDialogFragment : DialogFragment() { + private var _binding: DialogProgressBarBinding? = null + private val binding get() = _binding!! + + private lateinit var alertDialog: AlertDialog + + private lateinit var shaderProgressViewModel: ShaderProgressViewModel + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogProgressBarBinding.inflate(layoutInflater) + shaderProgressViewModel = + ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java] + + val title = requireArguments().getString(TITLE) + val message = requireArguments().getString(MESSAGE) + + isCancelable = false + alertDialog = MaterialAlertDialogBuilder(requireActivity()) + .setView(binding.root) + .setTitle(title) + .setMessage(message) + .create() + return alertDialog + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress -> + binding.progressBar.progress = progress + setUpdateText() + } + shaderProgressViewModel.max.observe(viewLifecycleOwner) { max -> + binding.progressBar.max = max + setUpdateText() + } + shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg -> + alertDialog.setMessage(msg) + } + synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + fun onUpdateProgress(msg: String, progress: Int, max: Int) { + shaderProgressViewModel.setProgress(progress) + shaderProgressViewModel.setMax(max) + shaderProgressViewModel.setMessage(msg) + } + + private fun setUpdateText() { + binding.progressText.text = String.format( + "%d/%d", + shaderProgressViewModel.progress.value, + shaderProgressViewModel.max.value + ) + } + + companion object { + const val TAG = "ProgressDialogFragment" + const val TITLE = "title" + const val MESSAGE = "message" + + fun newInstance(title: String, message: String): ShaderProgressDialogFragment { + val frag = ShaderProgressDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt new file mode 100755 index 000000000..e6e9a6fe8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) + +package org.yuzu.yuzu_emu.features + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.getPublicFilesDir +import java.io.* + +class DocumentProvider : DocumentsProvider() { + private val baseDirectory: File + get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) + + companion object { + private val DEFAULT_ROOT_PROJECTION: Array = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + + const val ROOT_ID: String = "root" + } + + override fun onCreate(): Boolean { + return true + } + + /** + * @return The [File] that corresponds to the document ID supplied by [getDocumentId] + */ + private fun getFile(documentId: String): File { + if (documentId.startsWith(ROOT_ID)) { + val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) + if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found") + return file + } else { + throw FileNotFoundException("'$documentId' is not in any known root") + } + } + + /** + * @return A unique ID for the provided [File] + */ + private fun getDocumentId(file: File): String { + return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" + } + + override fun queryRoots(projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + + cursor.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add(DocumentsContract.Root.COLUMN_SUMMARY, null) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + return includeFile(cursor, documentId, null) + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + return documentId?.startsWith(parentDocumentId!!) ?: false + } + + /** + * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file + */ + private fun File.resolveWithoutConflict(name: String): File { + var file = resolve(name) + if (file.exists()) { + var noConflictId = + 1 // Makes sure two files don't have the same name by adding a number to the end + val extension = name.substringAfterLast('.') + val baseName = name.substringBeforeLast('.') + while (file.exists()) + file = resolve("$baseName (${noConflictId++}).$extension") + } + return file + } + + override fun createDocument( + parentDocumentId: String?, + mimeType: String?, + displayName: String + ): String { + val parentFile = getFile(parentDocumentId!!) + val newFile = parentFile.resolveWithoutConflict(displayName) + + try { + if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { + if (!newFile.mkdir()) + throw IOException("Failed to create directory") + } else { + if (!newFile.createNewFile()) + throw IOException("Failed to create file") + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun deleteDocument(documentId: String?) { + val file = getFile(documentId!!) + if (!file.delete()) + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + + override fun removeDocument(documentId: String, parentDocumentId: String?) { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId) + + if (parent == file || file.parentFile == null || file.parentFile!! == parent) { + if (!file.delete()) + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } else { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun renameDocument(documentId: String?, displayName: String?): String { + if (displayName == null) + throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null") + + val sourceFile = getFile(documentId!!) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") + val destFile = sourceParentFile.resolve(displayName) + + try { + if (!sourceFile.renameTo(destFile)) + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") + } catch (e: Exception) { + throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}") + } + + return getDocumentId(destFile) + } + + private fun copyDocument( + sourceDocumentId: String, sourceParentDocumentId: String, + targetParentDocumentId: String? + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") + + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { + val parent = getFile(targetParentDocumentId!!) + val oldFile = getFile(sourceDocumentId) + val newFile = parent.resolveWithoutConflict(oldFile.name) + + try { + if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) + throw IOException("Couldn't create new file") + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + inStream.copyTo(outStream) + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, sourceParentDocumentId: String?, + targetParentDocumentId: String? + ): String { + try { + val newDocumentId = copyDocument( + sourceDocumentId, sourceParentDocumentId!!, + targetParentDocumentId + ) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (e: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") + } + } + + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { + val localDocumentId = documentId ?: file?.let { getDocumentId(it) } + val localFile = file ?: getFile(documentId!!) + + var flags = 0 + if (localFile.isDirectory && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else if (localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name + ) + add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + if (localFile == baseDirectory) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + + return cursor + } + + private fun getTypeForFile(file: File): Any { + return if (file.isDirectory) + DocumentsContract.Document.MIME_TYPE_DIR + else + getTypeForName(file.name) + } + + private fun getTypeForName(name: String): Any { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) + return mime + } + return "application/octect-stream" + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor { + var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + + val parent = getFile(parentDocumentId!!) + for (file in parent.listFiles()!!) + cursor = includeFile(cursor, null, file) + + return cursor + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = documentId?.let { getFile(it) } + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt new file mode 100755 index 000000000..a6e9833ee --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractBooleanSetting : AbstractSetting { + var boolean: Boolean +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100755 index 000000000..6fe4bc263 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractFloatSetting : AbstractSetting { + var float: Float +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt new file mode 100755 index 000000000..892b7dcfe --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractIntSetting : AbstractSetting { + var int: Int +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt new file mode 100755 index 000000000..258580209 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractSetting { + val key: String? + val section: String? + val isRuntimeEditable: Boolean + val valueAsString: String + val defaultValue: Any +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt new file mode 100755 index 000000000..0d02c5997 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractStringSetting : AbstractSetting { + var string: String +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt new file mode 100755 index 000000000..3dfd66779 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class BooleanSetting( + override val key: String, + override val section: String, + override val defaultValue: Boolean +) : AbstractBooleanSetting { + USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false); + + override var boolean: Boolean = defaultValue + + override val valueAsString: String + get() = boolean.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + USE_CUSTOM_RTC + ) + + fun from(key: String): BooleanSetting? = + BooleanSetting.values().firstOrNull { it.key == key } + + fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt new file mode 100755 index 000000000..e5545a916 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class FloatSetting( + override val key: String, + override val section: String, + override val defaultValue: Float +) : AbstractFloatSetting { + // No float settings currently exist + EMPTY_SETTING("", "", 0f); + + override var float: Float = defaultValue + + override val valueAsString: String + get() = float.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = emptyList() + + fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key } + + fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt new file mode 100755 index 000000000..c5722a5a1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class IntSetting( + override val key: String, + override val section: String, + override val defaultValue: Int +) : AbstractIntSetting { + RENDERER_USE_SPEED_LIMIT( + "use_speed_limit", + Settings.SECTION_RENDERER, + 1 + ), + USE_DOCKED_MODE( + "use_docked_mode", + Settings.SECTION_SYSTEM, + 0 + ), + RENDERER_USE_DISK_SHADER_CACHE( + "use_disk_shader_cache", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_FORCE_MAX_CLOCK( + "force_max_clock", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ASYNCHRONOUS_SHADERS( + "use_asynchronous_shaders", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_DEBUG( + "debug", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_SPEED_LIMIT( + "speed_limit", + Settings.SECTION_RENDERER, + 100 + ), + CPU_ACCURACY( + "cpu_accuracy", + Settings.SECTION_CPU, + 0 + ), + REGION_INDEX( + "region_index", + Settings.SECTION_SYSTEM, + -1 + ), + LANGUAGE_INDEX( + "language_index", + Settings.SECTION_SYSTEM, + 1 + ), + RENDERER_BACKEND( + "backend", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ACCURACY( + "gpu_accuracy", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_RESOLUTION( + "resolution_setup", + Settings.SECTION_RENDERER, + 2 + ), + RENDERER_VSYNC( + "use_vsync", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_SCALING_FILTER( + "scaling_filter", + Settings.SECTION_RENDERER, + 1 + ), + RENDERER_ANTI_ALIASING( + "anti_aliasing", + Settings.SECTION_RENDERER, + 0 + ), + RENDERER_ASPECT_RATIO( + "aspect_ratio", + Settings.SECTION_RENDERER, + 0 + ), + AUDIO_VOLUME( + "volume", + Settings.SECTION_AUDIO, + 100 + ); + + override var int: Int = defaultValue + + override val valueAsString: String + get() = int.toString() + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + RENDERER_USE_DISK_SHADER_CACHE, + RENDERER_ASYNCHRONOUS_SHADERS, + RENDERER_DEBUG, + RENDERER_BACKEND, + RENDERER_RESOLUTION, + RENDERER_VSYNC + ) + + fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key } + + fun clear() = IntSetting.values().forEach { it.int = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt new file mode 100755 index 000000000..474f598a9 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +/** + * A semantically-related group of Settings objects. These Settings are + * internally stored as a HashMap. + */ +class SettingSection(val name: String) { + val settings = HashMap() + + /** + * Convenience method; inserts a value directly into the backing HashMap. + * + * @param setting The Setting to be inserted. + */ + fun putSetting(setting: AbstractSetting) { + settings[setting.key!!] = setting + } + + /** + * Convenience method; gets a value directly from the backing HashMap. + * + * @param key Used to retrieve the Setting. + * @return A Setting object (you should probably cast this before using) + */ + fun getSetting(key: String): AbstractSetting? { + return settings[key] + } + + fun mergeSection(settingSection: SettingSection) { + for (setting in settingSection.settings.values) { + putSetting(setting) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt new file mode 100755 index 000000000..a904c2011 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import android.text.TextUtils +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import java.util.* + +class Settings { + private var gameId: String? = null + + var isLoaded = false + + /** + * A HashMap, SettingSection> that constructs a new SettingSection instead of returning null + * when getting a key not already in the map + */ + class SettingsSectionMap : HashMap() { + override operator fun get(key: String): SettingSection? { + if (!super.containsKey(key)) { + val section = SettingSection(key) + super.put(key, section) + return section + } + return super.get(key) + } + } + + var sections: HashMap = SettingsSectionMap() + + fun getSection(sectionName: String): SettingSection? { + return sections[sectionName] + } + + val isEmpty: Boolean + get() = sections.isEmpty() + + fun loadSettings(view: SettingsActivityView) { + sections = SettingsSectionMap() + loadYuzuSettings(view) + if (!TextUtils.isEmpty(gameId)) { + loadCustomGameSettings(gameId!!, view) + } + isLoaded = true + } + + private fun loadYuzuSettings(view: SettingsActivityView) { + for ((fileName) in configFileSectionsMap) { + sections.putAll(SettingsFile.readFile(fileName, view)) + } + } + + private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView) { + // Custom game settings + mergeSections(SettingsFile.readCustomGameSettings(gameId, view)) + } + + private fun mergeSections(updatedSections: HashMap) { + for ((key, updatedSection) in updatedSections) { + if (sections.containsKey(key)) { + val originalSection = sections[key] + originalSection!!.mergeSection(updatedSection!!) + } else { + sections[key] = updatedSection + } + } + } + + fun loadSettings(gameId: String, view: SettingsActivityView) { + this.gameId = gameId + loadSettings(view) + } + + fun saveSettings(view: SettingsActivityView) { + if (TextUtils.isEmpty(gameId)) { + view.showToastMessage( + YuzuApplication.appContext.getString(R.string.ini_saved), + false + ) + + for ((fileName, sectionNames) in configFileSectionsMap) { + val iniSections = TreeMap() + for (section in sectionNames) { + iniSections[section] = sections[section]!! + } + + SettingsFile.saveFile(fileName, iniSections, view) + } + } else { + // Custom game settings + view.showToastMessage( + YuzuApplication.appContext.getString(R.string.gameid_saved, gameId), + false + ) + + SettingsFile.saveCustomGameSettings(gameId, sections) + } + } + + companion object { + const val SECTION_GENERAL = "General" + const val SECTION_SYSTEM = "System" + const val SECTION_RENDERER = "Renderer" + const val SECTION_AUDIO = "Audio" + const val SECTION_CPU = "Cpu" + const val SECTION_THEME = "Theme" + + const val PREF_OVERLAY_INIT = "OverlayInit" + const val PREF_CONTROL_SCALE = "controlScale" + const val PREF_TOUCH_ENABLED = "isTouchEnabled" + const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0" + const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1" + const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2" + const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3" + const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4" + const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5" + const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6" + const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7" + const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8" + const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9" + const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10" + const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11" + const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12" + const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13" + const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14" + + 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_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout" + const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" + const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" + + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" + const val PREF_THEME = "Theme" + const val PREF_THEME_MODE = "ThemeMode" + const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" + + private val configFileSectionsMap: MutableMap> = HashMap() + + init { + configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] = + listOf( + SECTION_GENERAL, + SECTION_SYSTEM, + SECTION_RENDERER, + SECTION_AUDIO, + SECTION_CPU + ) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt new file mode 100755 index 000000000..bd9233d62 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import androidx.lifecycle.ViewModel + +class SettingsViewModel : ViewModel() { + val settings = Settings() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt new file mode 100755 index 000000000..63f95690c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +enum class StringSetting( + override val key: String, + override val section: String, + override val defaultValue: String +) : AbstractStringSetting { + CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0"); + + override var string: String = defaultValue + + override val valueAsString: String + get() = string + + override val isRuntimeEditable: Boolean + get() { + for (setting in NOT_RUNTIME_EDITABLE) { + if (setting == this) { + return false + } + } + return true + } + + companion object { + private val NOT_RUNTIME_EDITABLE = listOf( + CUSTOM_RTC + ) + + fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key } + + fun clear() = StringSetting.values().forEach { it.string = it.defaultValue } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100755 index 000000000..bc0bf7788 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting + +class DateTimeSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val key: String? = null, + private val defaultValue: String? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_DATETIME_SETTING + + val value: String + get() = if (setting != null) { + val setting = setting as AbstractStringSetting + setting.string + } else { + defaultValue!! + } + + fun setSelectedValue(datetime: String): AbstractStringSetting { + val stringSetting = setting as AbstractStringSetting + stringSetting.string = datetime + return stringSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt new file mode 100755 index 000000000..0f8edbfb0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class HeaderSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_HEADER +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt new file mode 100755 index 000000000..dc89b5774 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +class RunnableSetting( + titleId: Int, + descriptionId: Int, + val runnable: () -> Unit +) : SettingsItem(null, titleId, descriptionId) { + override val type = TYPE_RUNNABLE +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt new file mode 100755 index 000000000..07520849e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a [AbstractSetting] object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +abstract class SettingsItem( + var setting: AbstractSetting?, + val nameId: Int, + val descriptionId: Int +) { + abstract val type: Int + + val isEditable: Boolean + get() { + if (!NativeLibrary.isRunning()) return true + return setting?.isRuntimeEditable ?: false + } + + companion object { + const val TYPE_HEADER = 0 + const val TYPE_SWITCH = 1 + const val TYPE_SINGLE_CHOICE = 2 + const val TYPE_SLIDER = 3 + const val TYPE_SUBMENU = 4 + const val TYPE_STRING_SINGLE_CHOICE = 5 + const val TYPE_DATETIME_SETTING = 6 + const val TYPE_RUNNABLE = 7 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100755 index 000000000..9eac9904e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting + +class SingleChoiceSetting( + setting: AbstractIntSetting?, + titleId: Int, + descriptionId: Int, + val choicesId: Int, + val valuesId: Int, + val key: String? = null, + val defaultValue: Int? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SINGLE_CHOICE + + val selectedValue: Int + get() = if (setting != null) { + val setting = setting as AbstractIntSetting + setting.int + } else { + defaultValue!! + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Int): AbstractIntSetting { + val intSetting = setting as AbstractIntSetting + intSetting.int = selection + return intSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt new file mode 100755 index 000000000..842648ce4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.utils.Log +import kotlin.math.roundToInt + +class SliderSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val min: Int, + val max: Int, + val units: String, + val key: String? = null, + val defaultValue: Int? = null, +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SLIDER + + val selectedValue: Int + get() { + val setting = setting ?: return defaultValue!! + return when (setting) { + is AbstractIntSetting -> setting.int + is AbstractFloatSetting -> setting.float.roundToInt() + else -> { + Log.error("[SliderSetting] Error casting setting type.") + -1 + } + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Int): AbstractIntSetting { + val intSetting = setting as AbstractIntSetting + intSetting.int = selection + return intSetting + } + + /** + * Write a value to the backing float. If that float was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the float. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: Float): AbstractFloatSetting { + val floatSetting = setting as AbstractFloatSetting + floatSetting.float = selection + return floatSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt new file mode 100755 index 000000000..9e9b00d10 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting +import org.yuzu.yuzu_emu.features.settings.model.StringSetting + +class StringSingleChoiceSetting( + val key: String? = null, + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val choicesId: Array, + private val valuesId: Array?, + private val defaultValue: String? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_STRING_SINGLE_CHOICE + + fun getValueAt(index: Int): String? { + if (valuesId == null) return null + return if (index >= 0 && index < valuesId.size) { + valuesId[index] + } else "" + } + + val selectedValue: String + get() = if (setting != null) { + val setting = setting as AbstractStringSetting + setting.string + } else { + defaultValue!! + } + val selectValueIndex: Int + get() { + val selectedValue = selectedValue + for (i in valuesId!!.indices) { + if (valuesId[i] == selectedValue) { + return i + } + } + return -1 + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return the existing setting with the new value applied. + */ + fun setSelectedValue(selection: String): AbstractStringSetting { + val stringSetting = setting as AbstractStringSetting + stringSetting.string = selection + return stringSetting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt new file mode 100755 index 000000000..8c1af6396 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SubmenuSetting( + setting: AbstractSetting?, + titleId: Int, + descriptionId: Int, + val menuKey: String +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SUBMENU +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt new file mode 100755 index 000000000..90b198718 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SwitchSetting( + setting: AbstractSetting, + titleId: Int, + descriptionId: Int, + val key: String? = null, + val defaultValue: Any? = null +) : SettingsItem(setting, titleId, descriptionId) { + override val type = TYPE_SWITCH + + val isChecked: Boolean + get() { + if (setting == null) { + return defaultValue as Boolean + } + + // Try integer setting + try { + val setting = setting as AbstractIntSetting + return setting.int == 1 + } catch (_: ClassCastException) { + } + + // Try boolean setting + try { + val setting = setting as AbstractBooleanSetting + return setting.boolean + } catch (_: ClassCastException) { + } + return defaultValue as Boolean + } + + /** + * Write a value to the backing boolean. If that boolean was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param checked Pretty self explanatory. + * @return the existing setting with the new value applied. + */ + fun setChecked(checked: Boolean): AbstractSetting { + // Try integer setting + try { + val setting = setting as AbstractIntSetting + setting.int = if (checked) 1 else 0 + return setting + } catch (_: ClassCastException) { + } + + // Try boolean setting + val setting = setting as AbstractBooleanSetting + setting.boolean = checked + return setting + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt new file mode 100755 index 000000000..783122860 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.Menu +import android.view.View +import android.widget.Toast +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 android.view.ViewGroup.MarginLayoutParams +import androidx.activity.OnBackPressedCallback +import androidx.core.view.updatePadding +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.utils.* +import java.io.IOException + +class SettingsActivity : AppCompatActivity(), SettingsActivityView { + private val presenter = SettingsActivityPresenter(this) + + private lateinit var binding: ActivitySettingsBinding + + private val settingsViewModel: SettingsViewModel by viewModels() + + override val settings: Settings get() = settingsViewModel.settings + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + val launcher = intent + val gameID = launcher.getStringExtra(ARG_GAME_ID) + val menuTag = launcher.getStringExtra(ARG_MENU_TAG) + presenter.onCreate(savedInstanceState, menuTag!!, gameID!!) + + // Show "Back" button in the action bar for navigation + setSupportActionBar(binding.toolbarSettings) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + + 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 + ) + ) + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + }) + + setInsets() + } + + override fun onSupportNavigateUp(): Boolean { + navigateBack() + return true + } + + private fun navigateBack() { + if (supportFragmentManager.backStackEntryCount > 0) { + supportFragmentManager.popBackStack() + } else { + finish() + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater = menuInflater + inflater.inflate(R.menu.menu_settings, menu) + return true + } + + override fun onSaveInstanceState(outState: Bundle) { + // Critical: If super method is not called, rotations will be busted. + super.onSaveInstanceState(outState) + presenter.saveState(outState) + } + + override fun onStart() { + super.onStart() + presenter.onStart() + } + + /** + * If this is called, the user has left the settings screen (potentially through the + * home button) and will expect their changes to be persisted. So we kick off an + * IntentService which will do so on a background thread. + */ + override fun onStop() { + super.onStop() + presenter.onStop(isFinishing) + + // Update framebuffer layout when closing the settings + NativeLibrary.notifyOrientationChange( + EmulationMenuSettings.landscapeScreenLayout, + windowManager.defaultDisplay.rotation + ) + } + + override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) { + if (!addToStack && settingsFragment != null) { + return + } + + val transaction = supportFragmentManager.beginTransaction() + if (addToStack) { + if (areSystemAnimationsEnabled()) { + transaction.setCustomAnimations( + R.anim.anim_settings_fragment_in, + R.anim.anim_settings_fragment_out, + 0, + R.anim.anim_pop_settings_fragment_out + ) + } + transaction.addToBackStack(null) + } + transaction.replace( + R.id.frame_content, + SettingsFragment.newInstance(menuTag, gameId), + FRAGMENT_TAG + ) + transaction.commit() + } + + private fun areSystemAnimationsEnabled(): Boolean { + val duration = android.provider.Settings.Global.getFloat( + contentResolver, + android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f + ) + val transition = android.provider.Settings.Global.getFloat( + contentResolver, + android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f + ) + return duration != 0f && transition != 0f + } + + override fun onSettingsFileLoaded() { + val fragment: SettingsFragmentView? = settingsFragment + fragment?.loadSettingsList() + } + + override fun onSettingsFileNotFound() { + val fragment: SettingsFragmentView? = settingsFragment + fragment?.loadSettingsList() + } + + override fun showToastMessage(message: String, is_long: Boolean) { + Toast.makeText( + this, + message, + if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + ).show() + } + + override fun onSettingChanged() { + presenter.onSettingChanged() + } + + fun onSettingsReset() { + // Prevents saving to a non-existent settings file + presenter.onSettingsReset() + + // Reset the static memory representation of each setting + BooleanSetting.clear() + FloatSetting.clear() + IntSetting.clear() + StringSetting.clear() + + // Delete settings file because the user may have changed values that do not exist in the UI + val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + + showToastMessage(getString(R.string.settings_reset), true) + finish() + } + + fun setToolbarTitle(title: String) { + binding.toolbarSettingsLayout.title = title + } + + private val settingsFragment: SettingsFragment? + get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment? + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + view.updatePadding( + left = barInsets.left + cutoutInsets.left, + right = barInsets.right + cutoutInsets.right + ) + + val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left + mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right + binding.appbarSettings.layoutParams = mlpAppBar + + val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpShade + + windowInsets + } + } + + companion object { + private const val ARG_MENU_TAG = "menu_tag" + private const val ARG_GAME_ID = "game_id" + private const val FRAGMENT_TAG = "settings" + + fun launch(context: Context, menuTag: String?, gameId: String?) { + val settings = Intent(context, SettingsActivity::class.java) + settings.putExtra(ARG_MENU_TAG, menuTag) + settings.putExtra(ARG_GAME_ID, gameId) + context.startActivity(settings) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt new file mode 100755 index 000000000..4361d95fb --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.text.TextUtils +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.Log +import java.io.File + +class SettingsActivityPresenter(private val activityView: SettingsActivityView) { + val settings: Settings get() = activityView.settings + + private var shouldSave = false + private lateinit var menuTag: String + private lateinit var gameId: String + + fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) { + this.menuTag = menuTag + this.gameId = gameId + if (savedInstanceState != null) { + shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE) + } + } + + fun onStart() { + prepareDirectoriesIfNeeded() + } + + private fun loadSettingsUI() { + if (!settings.isLoaded) { + if (!TextUtils.isEmpty(gameId)) { + settings.loadSettings(gameId, activityView) + } else { + settings.loadSettings(activityView) + } + } + activityView.showSettingsFragment(menuTag, false, gameId) + activityView.onSettingsFileLoaded() + } + + private fun prepareDirectoriesIfNeeded() { + val configFile = + File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini") + if (!configFile.exists()) { + Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini") + Log.error("yuzu config file could not be found!") + } + + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start(activityView as Context) + } + loadSettingsUI() + } + + fun onStop(finishing: Boolean) { + if (finishing && shouldSave) { + Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...") + settings.saveSettings(activityView) + } + NativeLibrary.reloadSettings() + } + + fun onSettingChanged() { + shouldSave = true + } + + fun onSettingsReset() { + shouldSave = false + } + + fun saveState(outState: Bundle) { + outState.putBoolean(KEY_SHOULD_SAVE, shouldSave) + } + + companion object { + private const val KEY_SHOULD_SAVE = "should_save" + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt new file mode 100755 index 000000000..c186fc388 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import org.yuzu.yuzu_emu.features.settings.model.Settings + +/** + * Abstraction for the Activity that manages SettingsFragments. + */ +interface SettingsActivityView { + /** + * Show a new SettingsFragment. + * + * @param menuTag Identifier for the settings group that should be displayed. + * @param addToStack Whether or not this fragment should replace a previous one. + */ + fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) + + /** + * Called by a contained Fragment to get access to the Setting HashMap + * loaded from disk, so that each Fragment doesn't need to perform its own + * read operation. + * + * @return A HashMap of Settings. + */ + val settings: Settings + + /** + * Called when a load operation completes. + */ + fun onSettingsFileLoaded() + + /** + * Called when a load operation fails. + */ + fun onSettingsFileNotFound() + + /** + * Display a popup text message on screen. + * + * @param message The contents of the onscreen message. + * @param is_long Whether this should be a long Toast or short one. + */ + fun showToastMessage(message: String, is_long: Boolean) + + /** + * End the activity. + */ + fun finish() + + /** + * Called by a containing Fragment to tell the Activity that a setting was changed; + * unless this has been called, the Activity will not save to disk. + */ + fun onSettingChanged() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt new file mode 100755 index 000000000..1eb4899fc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -0,0 +1,340 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.content.DialogInterface +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.setFragmentResultListener +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogSliderBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting +import org.yuzu.yuzu_emu.features.settings.model.FloatSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* + +class SettingsAdapter( + private val fragmentView: SettingsFragmentView, + private val context: Context +) : RecyclerView.Adapter(), DialogInterface.OnClickListener { + private var settings: ArrayList? = null + private var clickedItem: SettingsItem? = null + private var clickedPosition: Int + private var dialog: AlertDialog? = null + private var sliderProgress = 0 + private var textSliderValue: TextView? = null + + private var defaultCancelListener = + DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } + + init { + clickedPosition = -1 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + SettingsItem.TYPE_HEADER -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SWITCH -> { + SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SLIDER -> { + SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SUBMENU -> { + SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_DATETIME_SETTING -> { + DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_RUNNABLE -> { + RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + else -> { + // TODO: Create an error view since we can't return null now + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + } + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private fun getItem(position: Int): SettingsItem { + return settings!![position] + } + + override fun getItemCount(): Int { + return if (settings != null) { + settings!!.size + } else { + 0 + } + } + + override fun getItemViewType(position: Int): Int { + return getItem(position).type + } + + fun setSettingsList(settings: ArrayList?) { + this.settings = settings + notifyDataSetChanged() + } + + fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) { + val setting = item.setChecked(checked) + fragmentView.putSetting(setting) + fragmentView.onSettingChanged() + } + + private fun onSingleChoiceClick(item: SingleChoiceSetting) { + clickedItem = item + val value = getSelectionForSingleChoiceValue(item) + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setSingleChoiceItems(item.choicesId, value, this) + .show() + } + + fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { + clickedPosition = position + onSingleChoiceClick(item) + } + + private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) { + clickedItem = item + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setSingleChoiceItems(item.choicesId, item.selectValueIndex, this) + .show() + } + + fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { + clickedPosition = position + onStringSingleChoiceClick(item) + } + + fun onDateTimeClick(item: DateTimeSetting, position: Int) { + clickedItem = item + clickedPosition = position + val storedTime = java.lang.Long.decode(item.value) * 1000 + + // Helper to extract hour and minute from epoch time + val calendar: Calendar = Calendar.getInstance() + calendar.timeInMillis = storedTime + calendar.timeZone = TimeZone.getTimeZone("UTC") + + var timeFormat: Int = TimeFormat.CLOCK_12H + if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) { + timeFormat = TimeFormat.CLOCK_24H + } + + val datePicker: MaterialDatePicker = MaterialDatePicker.Builder.datePicker() + .setSelection(storedTime) + .setTitleText(R.string.select_rtc_date) + .build() + val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() + .setTimeFormat(timeFormat) + .setHour(calendar.get(Calendar.HOUR_OF_DAY)) + .setMinute(calendar.get(Calendar.MINUTE)) + .setTitleText(R.string.select_rtc_time) + .build() + + datePicker.addOnPositiveButtonClickListener { + timePicker.show( + (fragmentView.activityView as AppCompatActivity).supportFragmentManager, + "TimePicker" + ) + } + timePicker.addOnPositiveButtonClickListener { + var epochTime: Long = datePicker.selection!! / 1000 + epochTime += timePicker.hour.toLong() * 60 * 60 + epochTime += timePicker.minute.toLong() * 60 + val rtcString = epochTime.toString() + if (item.value != rtcString) { + fragmentView.onSettingChanged() + } + notifyItemChanged(clickedPosition) + val setting = item.setSelectedValue(rtcString) + fragmentView.putSetting(setting) + clickedItem = null + } + datePicker.show( + (fragmentView.activityView as AppCompatActivity).supportFragmentManager, + "DatePicker" + ) + } + + fun onSliderClick(item: SliderSetting, position: Int) { + clickedItem = item + clickedPosition = position + sliderProgress = item.selectedValue + + val inflater = LayoutInflater.from(context) + val sliderBinding = DialogSliderBinding.inflate(inflater) + + textSliderValue = sliderBinding.textValue + textSliderValue!!.text = sliderProgress.toString() + sliderBinding.textUnits.text = item.units + + sliderBinding.slider.apply { + valueFrom = item.min.toFloat() + valueTo = item.max.toFloat() + value = sliderProgress.toFloat() + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + sliderProgress = value.toInt() + textSliderValue!!.text = sliderProgress.toString() + } + } + + dialog = MaterialAlertDialogBuilder(context) + .setTitle(item.nameId) + .setView(sliderBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int -> + sliderBinding.slider.value = item.defaultValue!!.toFloat() + onClick(dialog, which) + } + .show() + } + + fun onSubmenuClick(item: SubmenuSetting) { + fragmentView.loadSubMenu(item.menuKey) + } + + override fun onClick(dialog: DialogInterface, which: Int) { + when (clickedItem) { + is SingleChoiceSetting -> { + val scSetting = clickedItem as SingleChoiceSetting + val value = getValueForSingleChoiceSelection(scSetting, which) + if (scSetting.selectedValue != value) { + fragmentView.onSettingChanged() + } + + // Get the backing Setting, which may be null (if for example it was missing from the file) + val setting = scSetting.setSelectedValue(value) + fragmentView.putSetting(setting) + closeDialog() + } + + is StringSingleChoiceSetting -> { + val scSetting = clickedItem as StringSingleChoiceSetting + val value = scSetting.getValueAt(which) + if (scSetting.selectedValue != value) fragmentView.onSettingChanged() + val setting = scSetting.setSelectedValue(value!!) + fragmentView.putSetting(setting) + closeDialog() + } + + is SliderSetting -> { + val sliderSetting = clickedItem as SliderSetting + if (sliderSetting.selectedValue != sliderProgress) { + fragmentView.onSettingChanged() + } + if (sliderSetting.setting is FloatSetting) { + val value = sliderProgress.toFloat() + val setting = sliderSetting.setSelectedValue(value) + fragmentView.putSetting(setting) + } else { + val setting = sliderSetting.setSelectedValue(sliderProgress) + fragmentView.putSetting(setting) + } + closeDialog() + } + } + clickedItem = null + sliderProgress = -1 + } + + fun onLongClick(setting: AbstractSetting, position: Int): Boolean { + MaterialAlertDialogBuilder(context) + .setMessage(R.string.reset_setting_confirmation) + .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int -> + when (setting) { + is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean + is AbstractFloatSetting -> setting.float = setting.defaultValue as Float + is AbstractIntSetting -> setting.int = setting.defaultValue as Int + is AbstractStringSetting -> setting.string = setting.defaultValue as String + } + notifyItemChanged(position) + fragmentView.onSettingChanged() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + + return true + } + + fun closeDialog() { + if (dialog != null) { + if (clickedPosition != -1) { + notifyItemChanged(clickedPosition) + clickedPosition = -1 + } + dialog!!.dismiss() + dialog = null + } + } + + private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { + val valuesId = item.valuesId + return if (valuesId > 0) { + val valuesArray = context.resources.getIntArray(valuesId) + valuesArray[which] + } else { + which + } + } + + private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { + val value = item.selectedValue + val valuesId = item.valuesId + if (valuesId > 0) { + val valuesArray = context.resources.getIntArray(valuesId) + for (index in valuesArray.indices) { + val current = valuesArray[index] + if (current == value) { + return index + } + } + } else { + return value + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt new file mode 100755 index 000000000..867147950 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem + +class SettingsFragment : Fragment(), SettingsFragmentView { + override var activityView: SettingsActivityView? = null + + private val fragmentPresenter = SettingsFragmentPresenter(this) + private var settingsAdapter: SettingsAdapter? = null + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + override fun onAttach(context: Context) { + super.onAttach(context) + activityView = requireActivity() as SettingsActivityView + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG) + val gameId = requireArguments().getString(ARGUMENT_GAME_ID) + fragmentPresenter.onCreate(menuTag!!, gameId!!) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + settingsAdapter = SettingsAdapter(this, requireActivity()) + val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) + dividerDecoration.isLastItemDecorated = false + binding.listSettings.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(activity) + addItemDecoration(dividerDecoration) + } + fragmentPresenter.onViewCreated() + + setInsets() + } + + override fun onDetach() { + super.onDetach() + activityView = null + if (settingsAdapter != null) { + settingsAdapter!!.closeDialog() + } + } + + override fun showSettingsList(settingsList: ArrayList) { + settingsAdapter!!.setSettingsList(settingsList) + } + + override fun loadSettingsList() { + fragmentPresenter.loadSettingsList() + } + + override fun loadSubMenu(menuKey: String) { + activityView!!.showSettingsFragment( + menuKey, + true, + requireArguments().getString(ARGUMENT_GAME_ID)!! + ) + } + + override fun showToastMessage(message: String?, is_long: Boolean) { + activityView!!.showToastMessage(message!!, is_long) + } + + override fun putSetting(setting: AbstractSetting) { + fragmentPresenter.putSetting(setting) + } + + override fun onSettingChanged() { + activityView!!.onSettingChanged() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updatePadding(bottom = insets.bottom) + windowInsets + } + } + + companion object { + private const val ARGUMENT_MENU_TAG = "menu_tag" + private const val ARGUMENT_GAME_ID = "game_id" + + fun newInstance(menuTag: String?, gameId: String?): Fragment { + val fragment = SettingsFragment() + val arguments = Bundle() + arguments.putString(ARGUMENT_MENU_TAG, menuTag) + arguments.putString(ARGUMENT_GAME_ID, gameId) + fragment.arguments = arguments + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100755 index 000000000..d9abc358a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -0,0 +1,453 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.SharedPreferences +import android.os.Build +import android.text.TextUtils +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment +import org.yuzu.yuzu_emu.utils.ThemeHelper + +class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) { + private var menuTag: String? = null + private lateinit var gameId: String + private var settingsList: ArrayList? = null + + private val settingsActivity get() = fragmentView.activityView as SettingsActivity + private val settings get() = fragmentView.activityView!!.settings + + private lateinit var preferences: SharedPreferences + + fun onCreate(menuTag: String, gameId: String) { + this.gameId = gameId + this.menuTag = menuTag + } + + fun onViewCreated() { + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + loadSettingsList() + } + + fun putSetting(setting: AbstractSetting) { + if (setting.section == null) { + return + } + + val section = settings.getSection(setting.section!!)!! + if (section.getSetting(setting.key!!) == null) { + section.putSetting(setting) + } + } + + fun loadSettingsList() { + if (!TextUtils.isEmpty(gameId)) { + settingsActivity.setToolbarTitle("Game Settings: $gameId") + } + val sl = ArrayList() + if (menuTag == null) { + return + } + when (menuTag) { + SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl) + Settings.SECTION_GENERAL -> addGeneralSettings(sl) + Settings.SECTION_SYSTEM -> addSystemSettings(sl) + Settings.SECTION_RENDERER -> addGraphicsSettings(sl) + Settings.SECTION_AUDIO -> addAudioSettings(sl) + Settings.SECTION_THEME -> addThemeSettings(sl) + else -> { + fragmentView.showToastMessage("Unimplemented menu", false) + return + } + } + settingsList = sl + fragmentView.showSettingsList(settingsList!!) + } + + private fun addConfigSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_advanced_settings)) + sl.apply { + add( + SubmenuSetting( + null, + R.string.preferences_general, + 0, + Settings.SECTION_GENERAL + ) + ) + add( + SubmenuSetting( + null, + R.string.preferences_system, + 0, + Settings.SECTION_SYSTEM + ) + ) + add( + SubmenuSetting( + null, + R.string.preferences_graphics, + 0, + Settings.SECTION_RENDERER + ) + ) + add( + SubmenuSetting( + null, + R.string.preferences_audio, + 0, + Settings.SECTION_AUDIO + ) + ) + add( + RunnableSetting( + R.string.reset_to_default, + 0 + ) { + ResetSettingsDialogFragment().show( + settingsActivity.supportFragmentManager, + ResetSettingsDialogFragment.TAG + ) + } + ) + } + } + + private fun addGeneralSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general)) + sl.apply { + add( + SwitchSetting( + IntSetting.RENDERER_USE_SPEED_LIMIT, + R.string.frame_limit_enable, + R.string.frame_limit_enable_description, + IntSetting.RENDERER_USE_SPEED_LIMIT.key, + IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue + ) + ) + add( + SliderSetting( + IntSetting.RENDERER_SPEED_LIMIT, + R.string.frame_limit_slider, + R.string.frame_limit_slider_description, + 1, + 200, + "%", + IntSetting.RENDERER_SPEED_LIMIT.key, + IntSetting.RENDERER_SPEED_LIMIT.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.CPU_ACCURACY, + R.string.cpu_accuracy, + 0, + R.array.cpuAccuracyNames, + R.array.cpuAccuracyValues, + IntSetting.CPU_ACCURACY.key, + IntSetting.CPU_ACCURACY.defaultValue + ) + ) + } + } + + private fun addSystemSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system)) + sl.apply { + add( + SwitchSetting( + IntSetting.USE_DOCKED_MODE, + R.string.use_docked_mode, + R.string.use_docked_mode_description, + IntSetting.USE_DOCKED_MODE.key, + IntSetting.USE_DOCKED_MODE.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.REGION_INDEX, + R.string.emulated_region, + 0, + R.array.regionNames, + R.array.regionValues, + IntSetting.REGION_INDEX.key, + IntSetting.REGION_INDEX.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.LANGUAGE_INDEX, + R.string.emulated_language, + 0, + R.array.languageNames, + R.array.languageValues, + IntSetting.LANGUAGE_INDEX.key, + IntSetting.LANGUAGE_INDEX.defaultValue + ) + ) + add( + SwitchSetting( + BooleanSetting.USE_CUSTOM_RTC, + R.string.use_custom_rtc, + R.string.use_custom_rtc_description, + BooleanSetting.USE_CUSTOM_RTC.key, + BooleanSetting.USE_CUSTOM_RTC.defaultValue + ) + ) + add( + DateTimeSetting( + StringSetting.CUSTOM_RTC, + R.string.set_custom_rtc, + 0, + StringSetting.CUSTOM_RTC.key, + StringSetting.CUSTOM_RTC.defaultValue + ) + ) + } + } + + private fun addGraphicsSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) + sl.apply { + add( + SingleChoiceSetting( + IntSetting.RENDERER_BACKEND, + R.string.renderer_api, + 0, + R.array.rendererApiNames, + R.array.rendererApiValues, + IntSetting.RENDERER_BACKEND.key, + IntSetting.RENDERER_BACKEND.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_ACCURACY, + R.string.renderer_accuracy, + 0, + R.array.rendererAccuracyNames, + R.array.rendererAccuracyValues, + IntSetting.RENDERER_ACCURACY.key, + IntSetting.RENDERER_ACCURACY.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_RESOLUTION, + R.string.renderer_resolution, + 0, + R.array.rendererResolutionNames, + R.array.rendererResolutionValues, + IntSetting.RENDERER_RESOLUTION.key, + IntSetting.RENDERER_RESOLUTION.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_VSYNC, + R.string.renderer_vsync, + 0, + R.array.rendererVSyncNames, + R.array.rendererVSyncValues, + IntSetting.RENDERER_VSYNC.key, + IntSetting.RENDERER_VSYNC.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_SCALING_FILTER, + R.string.renderer_scaling_filter, + 0, + R.array.rendererScalingFilterNames, + R.array.rendererScalingFilterValues, + IntSetting.RENDERER_SCALING_FILTER.key, + IntSetting.RENDERER_SCALING_FILTER.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_ANTI_ALIASING, + R.string.renderer_anti_aliasing, + 0, + R.array.rendererAntiAliasingNames, + R.array.rendererAntiAliasingValues, + IntSetting.RENDERER_ANTI_ALIASING.key, + IntSetting.RENDERER_ANTI_ALIASING.defaultValue + ) + ) + add( + SingleChoiceSetting( + IntSetting.RENDERER_ASPECT_RATIO, + R.string.renderer_aspect_ratio, + 0, + R.array.rendererAspectRatioNames, + R.array.rendererAspectRatioValues, + IntSetting.RENDERER_ASPECT_RATIO.key, + IntSetting.RENDERER_ASPECT_RATIO.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_USE_DISK_SHADER_CACHE, + R.string.use_disk_shader_cache, + R.string.use_disk_shader_cache_description, + IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key, + IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_FORCE_MAX_CLOCK, + R.string.renderer_force_max_clock, + R.string.renderer_force_max_clock_description, + IntSetting.RENDERER_FORCE_MAX_CLOCK.key, + IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS, + R.string.renderer_asynchronous_shaders, + R.string.renderer_asynchronous_shaders_description, + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key, + IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue + ) + ) + add( + SwitchSetting( + IntSetting.RENDERER_DEBUG, + R.string.renderer_debug, + R.string.renderer_debug_description, + IntSetting.RENDERER_DEBUG.key, + IntSetting.RENDERER_DEBUG.defaultValue + ) + ) + } + } + + private fun addAudioSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio)) + sl.add( + SliderSetting( + IntSetting.AUDIO_VOLUME, + R.string.audio_volume, + R.string.audio_volume_description, + 0, + 100, + "%", + IntSetting.AUDIO_VOLUME.key, + IntSetting.AUDIO_VOLUME.defaultValue + ) + ) + } + + private fun addThemeSettings(sl: ArrayList) { + settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme)) + sl.apply { + val theme: AbstractIntSetting = object : AbstractIntSetting { + override var int: Int + get() = preferences.getInt(Settings.PREF_THEME, 0) + set(value) { + preferences.edit() + .putInt(Settings.PREF_THEME, value) + .apply() + settingsActivity.recreate() + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getInt(Settings.PREF_THEME, 0).toString() + override val defaultValue: Any = 0 + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add( + SingleChoiceSetting( + theme, + R.string.change_app_theme, + 0, + R.array.themeEntriesA12, + R.array.themeValuesA12 + ) + ) + } else { + add( + SingleChoiceSetting( + theme, + R.string.change_app_theme, + 0, + R.array.themeEntries, + R.array.themeValues + ) + ) + } + + val themeMode: AbstractIntSetting = object : AbstractIntSetting { + override var int: Int + get() = preferences.getInt(Settings.PREF_THEME_MODE, -1) + set(value) { + preferences.edit() + .putInt(Settings.PREF_THEME_MODE, value) + .apply() + ThemeHelper.setThemeMode(settingsActivity) + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString() + override val defaultValue: Any = -1 + } + + add( + SingleChoiceSetting( + themeMode, + R.string.change_theme_mode, + 0, + R.array.themeModeEntries, + R.array.themeModeValues + ) + ) + + val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { + override var boolean: Boolean + get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value) + .apply() + settingsActivity.recreate() + } + override val key: String? = null + override val section: String? = null + override val isRuntimeEditable: Boolean = false + override val valueAsString: String + get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + .toString() + override val defaultValue: Any = false + } + + add( + SwitchSetting( + blackBackgrounds, + R.string.use_black_backgrounds, + R.string.use_black_backgrounds_description + ) + ) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt new file mode 100755 index 000000000..1ebe35eaa --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem + +/** + * Abstraction for a screen showing a list of settings. Instances of + * this type of view will each display a layer of the setting hierarchy. + */ +interface SettingsFragmentView { + /** + * Pass an ArrayList to the View so that it can be displayed on screen. + * + * @param settingsList The result of converting the HashMap to an ArrayList + */ + fun showSettingsList(settingsList: ArrayList) + + /** + * Instructs the Fragment to load the settings screen. + */ + fun loadSettingsList() + + /** + * @return The Fragment's containing activity. + */ + val activityView: SettingsActivityView? + + /** + * Tell the Fragment to tell the containing Activity to show a new + * Fragment containing a submenu of settings. + * + * @param menuKey Identifier for the settings group that should be shown. + */ + fun loadSubMenu(menuKey: String) + + /** + * Tell the Fragment to tell the containing activity to display a toast message. + * + * @param message Text to be shown in the Toast + * @param is_long Whether this should be a long Toast or short one. + */ + fun showToastMessage(message: String?, is_long: Boolean) + + /** + * Have the fragment add a setting to the HashMap. + * + * @param setting The (possibly previously missing) new setting. + */ + fun putSetting(setting: AbstractSetting) + + /** + * Have the fragment tell the containing Activity that a setting was modified. + */ + fun onSettingChanged() +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100755 index 000000000..04c045e77 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +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.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + val epochTime = setting.value.toLong() + val instant = Instant.ofEpochMilli(epochTime * 1000) + val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + binding.textSettingDescription.text = dateFormatter.format(zonedTime) + } + } + + 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.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100755 index 000000000..f5bcf705c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_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.setText(item.nameId) + } + + override fun onClick(clicked: View) { + // no-op + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt new file mode 100755 index 000000000..0c0bb93fc --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: RunnableSetting + + override fun bind(item: SettingsItem) { + setting = item as RunnableSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + setting.runnable.invoke() + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt new file mode 100755 index 000000000..f56460893 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) : + RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener { + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + } + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + abstract fun bind(item: SettingsItem) + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + abstract override fun onClick(clicked: View) + + abstract override fun onLongClick(clicked: View): Boolean +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt new file mode 100755 index 000000000..de764a27f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SettingsItem + + override fun bind(item: SettingsItem) { + setting = item + binding.textSettingName.setText(item.nameId) + binding.textSettingDescription.visibility = View.VISIBLE + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + } else if (item is SingleChoiceSetting) { + val resMgr = binding.textSettingDescription.context.resources + val values = resMgr.getIntArray(item.valuesId) + for (i in values.indices) { + if (values[i] == item.selectedValue) { + binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i] + } + } + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + if (!setting.isEditable) { + return + } + + if (setting is SingleChoiceSetting) { + adapter.onSingleChoiceClick( + (setting as SingleChoiceSetting), + bindingAdapterPosition + ) + } else if (setting is StringSingleChoiceSetting) { + adapter.onStringSingleChoiceClick( + (setting as StringSingleChoiceSetting), + bindingAdapterPosition + ) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt new file mode 100755 index 000000000..cc3f39aa5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SliderSetting + + override fun bind(item: SettingsItem) { + setting = item as SliderSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onSliderClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100755 index 000000000..c545b4174 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var item: SubmenuSetting + + override fun bind(item: SettingsItem) { + this.item = item as SubmenuSetting + binding.textSettingName.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.visibility = View.GONE + } + } + + override fun onClick(clicked: View) { + adapter.onSubmenuClick(item) + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100755 index 000000000..b163bd6ca --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import android.widget.CompoundButton +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +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.setText(item.nameId) + if (item.descriptionId != 0) { + binding.textSettingDescription.setText(item.descriptionId) + binding.textSettingDescription.visibility = View.VISIBLE + } else { + binding.textSettingDescription.text = "" + binding.textSettingDescription.visibility = View.GONE + } + binding.switchWidget.isChecked = setting.isChecked + binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked) + } + + binding.switchWidget.isEnabled = setting.isEditable + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + binding.switchWidget.toggle() + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting.setting!!, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt new file mode 100755 index 000000000..c502fdca0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.utils + +import org.ini4j.Wini +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.* +import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView +import org.yuzu.yuzu_emu.utils.BiMap +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.Log +import java.io.* +import java.util.* + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +object SettingsFile { + const val FILE_NAME_CONFIG = "config" + + private var sectionsMap = BiMap() + + /** + * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param ini The ini file to load the settings from + * @param isCustomGame + * @param view The current view. + * @return An Observable that emits a HashMap of the file's contents, then completes. + */ + private fun readFile( + ini: File?, + isCustomGame: Boolean, + view: SettingsActivityView? + ): HashMap { + val sections: HashMap = SettingsSectionMap() + var reader: BufferedReader? = null + try { + reader = BufferedReader(FileReader(ini)) + var current: SettingSection? = null + var line: String? + while (reader.readLine().also { line = it } != null) { + if (line!!.startsWith("[") && line!!.endsWith("]")) { + current = sectionFromLine(line!!, isCustomGame) + sections[current.name] = current + } else if (current != null) { + val setting = settingFromLine(line!!) + if (setting != null) { + current.putSetting(setting) + } + } + } + } catch (e: FileNotFoundException) { + Log.error("[SettingsFile] File not found: " + e.message) + view?.onSettingsFileNotFound() + } catch (e: IOException) { + Log.error("[SettingsFile] Error reading from: " + e.message) + view?.onSettingsFileNotFound() + } finally { + if (reader != null) { + try { + reader.close() + } catch (e: IOException) { + Log.error("[SettingsFile] Error closing: " + e.message) + } + } + } + return sections + } + + fun readFile(fileName: String, view: SettingsActivityView): HashMap { + return readFile(getSettingsFile(fileName), false, view) + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param gameId the id of the game to load it's settings. + * @param view The current view. + */ + fun readCustomGameSettings( + gameId: String, + view: SettingsActivityView + ): HashMap { + return readFile(getCustomGameSettingsFile(gameId), true, view) + } + + /** + * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error + * telling why it failed. + * + * @param fileName The target filename without a path or extension. + * @param sections The HashMap containing the Settings we want to serialize. + * @param view The current view. + */ + fun saveFile( + fileName: String, + sections: TreeMap, + view: SettingsActivityView + ) { + val ini = getSettingsFile(fileName) + try { + val writer = Wini(ini) + val keySet: Set = sections.keys + for (key in keySet) { + val section = sections[key] + writeSection(writer, section!!) + } + writer.store() + } catch (e: IOException) { + Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message) + view.showToastMessage( + YuzuApplication.appContext + .getString(R.string.error_saving, fileName, e.message), + false + ) + } + } + + fun saveCustomGameSettings(gameId: String?, sections: HashMap) { + val sortedSections: Set = TreeSet(sections.keys) + for (sectionKey in sortedSections) { + val section = sections[sectionKey] + val settings = section!!.settings + val sortedKeySet: Set = TreeSet(settings.keys) + for (settingKey in sortedKeySet) { + val setting = settings[settingKey] + NativeLibrary.setUserSetting( + gameId, mapSectionNameFromIni( + section.name + ), setting!!.key, setting.valueAsString + ) + } + } + } + + private fun mapSectionNameFromIni(generalSectionName: String): String? { + return if (sectionsMap.getForward(generalSectionName) != null) { + sectionsMap.getForward(generalSectionName) + } else generalSectionName + } + + private fun mapSectionNameToIni(generalSectionName: String): String { + return if (sectionsMap.getBackward(generalSectionName) != null) { + sectionsMap.getBackward(generalSectionName).toString() + } else generalSectionName + } + + fun getSettingsFile(fileName: String): File { + return File( + DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini" + ) + } + + private fun getCustomGameSettingsFile(gameId: String): File { + return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini") + } + + private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection { + var sectionName: String = line.substring(1, line.length - 1) + if (isCustomGame) { + sectionName = mapSectionNameToIni(sectionName) + } + return SettingSection(sectionName) + } + + /** + * For a line of text, determines what type of data is being represented, and returns + * a Setting object containing this data. + * + * @param line The line of text being parsed. + * @return A typed Setting containing the key/value contained in the line. + */ + private fun settingFromLine(line: String): AbstractSetting? { + val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + if (splitLine.size != 2) { + return null + } + val key = splitLine[0].trim { it <= ' ' } + val value = splitLine[1].trim { it <= ' ' } + if (value.isEmpty()) { + return null + } + + val booleanSetting = BooleanSetting.from(key) + if (booleanSetting != null) { + booleanSetting.boolean = value.toBoolean() + return booleanSetting + } + + val intSetting = IntSetting.from(key) + if (intSetting != null) { + intSetting.int = value.toInt() + return intSetting + } + + val floatSetting = FloatSetting.from(key) + if (floatSetting != null) { + floatSetting.float = value.toFloat() + return floatSetting + } + + val stringSetting = StringSetting.from(key) + if (stringSetting != null) { + stringSetting.string = value + return stringSetting + } + + return null + } + + /** + * Writes the contents of a Section HashMap to disk. + * + * @param parser A Wini pointed at a file on disk. + * @param section A section containing settings to be written to the file. + */ + private fun writeSection(parser: Wini, section: SettingSection) { + // Write the section header. + val header = section.name + + // Write this section's values. + val settings = section.settings + val keySet: Set = settings.keys + for (key in keySet) { + val setting = settings[key] + parser.put(header, setting!!.key, setting.valueAsString) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt new file mode 100755 index 000000000..0314feff6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +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.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.core.content.ContextCompat +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.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding +import org.yuzu.yuzu_emu.model.HomeViewModel + +class AboutFragment : Fragment() { + private var _binding: FragmentAboutBinding? = 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 = FragmentAboutBinding.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.imageLogo.setOnLongClickListener { + Toast.makeText( + requireContext(), + R.string.gaia_is_not_real, + Toast.LENGTH_SHORT + ).show() + true + } + + binding.buttonContributors.setOnClickListener { openLink(getString(R.string.contributors_link)) } + + binding.textBuildHash.text = BuildConfig.GIT_HASH + binding.buttonBuildHash.setOnClickListener { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) } + binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) } + binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_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 + + val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.appbarAbout.layoutParams = mlpAppBar + + val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.scrollAbout.layoutParams = mlpScrollAbout + + binding.contentAbout.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt new file mode 100755 index 000000000..d8bbc1ce4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding +import org.yuzu.yuzu_emu.model.HomeViewModel + +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 + + val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.appbarEa.layoutParams = mlpAppBar + + binding.scrollEa.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt new file mode 100755 index 000000000..ce3f2639a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -0,0 +1,502 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.AlertDialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Color +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.* +import android.widget.TextView +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.fragment.app.Fragment +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.* +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var preferences: SharedPreferences + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private lateinit var game: Game + + 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) + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!! + emulationState = EmulationState(game.path) + } + + /** + * 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?) { + binding.surfaceEmulation.holder.addCallback(this) + binding.showFpsText.setTextColor(Color.YELLOW) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + // Setup overlay. + updateShowFpsOverlay() + + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + 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 + ) + } + true + } + + R.id.menu_settings -> { + SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_exit -> { + emulationState.stop() + requireActivity().finish() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open() + } + }) + } + + override fun onResume() { + super.onResume() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start(requireContext()) + } + emulationState.run(emulationActivity!!.isActivityRecreated) + } + + override fun onPause() { + if (emulationState.isRunning) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + fun refreshInputOverlay() { + binding.surfaceInputOverlay.refreshControls() + } + + fun resetInputOverlay() { + // Reset button scale + preferences.edit() + .putInt(Settings.PREF_CONTROL_SCALE, 50) + .apply() + binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() } + } + + private fun updateShowFpsOverlay() { + if (EmulationMenuSettings.showFps) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + val perfStats = NativeLibrary.getPerfStats() + if (perfStats[FPS] > 0 && _binding != null) { + binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS]) + } + + if (!emulationState.isStopped) { + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + binding.showFpsText.text = resources.getString(R.string.emulation_game_loading) + binding.showFpsText.visibility = View.VISIBLE + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + binding.showFpsText.visibility = View.GONE + } + } + + 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 = EmulationMenuSettings.showFps + findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter + findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide + findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay + findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback + } + + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.showFps = it.isChecked + updateShowFpsOverlay() + true + } + + R.id.menu_edit_overlay -> { + binding.drawerLayout.close() + binding.surfaceInputOverlay.requestFocus() + startConfiguringControls() + true + } + + R.id.menu_toggle_controls -> { + val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + val optionsArray = BooleanArray(15) + for (i in 0..14) { + optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13) + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + preferences.edit() + .putBoolean("buttonToggle$indexSelected", isChecked) + .apply() + } + .setPositiveButton(android.R.string.ok) { _, _ -> + refreshInputOverlay() + } + .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]; + for (i in 0..14) { + optionsArray[i] = isChecked; + dialog.listView.setItemChecked(i, isChecked) + preferences.edit() + .putBoolean("buttonToggle$i", isChecked) + .apply() + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.showOverlay = it.isChecked + refreshInputOverlay() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.joystickRelCenter = it.isChecked + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.dpadSlide = it.isChecked + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + EmulationMenuSettings.hapticFeedback = it.isChecked + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + fun startConfiguringControls() { + binding.doneControlConfig.visibility = View.VISIBLE + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + fun stopConfiguringControls() { + binding.doneControlConfig.visibility = View.GONE + binding.surfaceInputOverlay.setIsInEditMode(false) + } + + val isConfiguringControls: Boolean + get() = binding.surfaceInputOverlay.isInEditMode + + 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.setPadding(left, cutInsets.top, right, 0) + + binding.showFpsText.setPadding( + cutInsets.left, + cutInsets.top, + cutInsets.right, + cutInsets.bottom + ) + windowInsets + } + } + + private class EmulationState(private val gamePath: String) { + private var state: State + private var surface: Surface? = null + private var runWhenSurfaceIsValid = false + + 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.") + + // Release the surface before pausing, since emulation has to be running for that. + NativeLibrary.surfaceDestroyed() + NativeLibrary.pauseEmulation() + + state = State.PAUSED + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean) { + 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() + } else { + runWhenSurfaceIsValid = true + } + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (runWhenSurfaceIsValid) { + runWithValidSurface() + } + } + + @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 -> { + NativeLibrary.surfaceDestroyed() + 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() { + runWhenSurfaceIsValid = false + when (state) { + State.STOPPED -> { + NativeLibrary.surfaceChanged(surface) + val emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.surfaceChanged(surface) + 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()!!) + + fun newInstance(game: Game): EmulationFragment { + val args = Bundle() + args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game) + val fragment = EmulationFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt new file mode 100755 index 000000000..3a334a74c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -0,0 +1,291 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.DialogInterface +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.DocumentsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +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 androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter +import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding +import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.HomeSetting +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.GpuDriverHelper + +class HomeSettingsFragment : Fragment() { + private var _binding: FragmentHomeSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var mainActivity: MainActivity + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentHomeSettingsBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + val optionsList: MutableList = mutableListOf( + HomeSetting( + R.string.advanced_settings, + R.string.settings_description, + R.drawable.ic_settings + ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }, + HomeSetting( + R.string.open_user_folder, + R.string.open_user_folder_description, + R.drawable.ic_folder_open + ) { openFileManager() }, + HomeSetting( + R.string.preferences_theme, + R.string.theme_and_color_description, + R.drawable.ic_palette + ) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") }, + HomeSetting( + R.string.install_gpu_driver, + R.string.install_gpu_driver_description, + R.drawable.ic_exit + ) { driverInstaller() }, + HomeSetting( + R.string.install_amiibo_keys, + R.string.install_amiibo_keys_description, + R.drawable.ic_nfc + ) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }, + HomeSetting( + R.string.select_games_folder, + R.string.select_games_folder_description, + R.drawable.ic_add + ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + HomeSetting( + R.string.install_prod_keys, + R.string.install_prod_keys_description, + R.drawable.ic_unlock + ) { mainActivity.getProdKey.launch(arrayOf("*/*")) }, + HomeSetting( + R.string.about, + R.string.about_description, + R.drawable.ic_info_outline + ) { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) + } + ) + + if (!BuildConfig.PREMIUM) { + optionsList.add( + 0, + HomeSetting( + R.string.get_early_access, + R.string.get_early_access_description, + R.drawable.ic_diamond + ) { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) + } + ) + } + + binding.homeSettingsList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList) + } + + setInsets() + } + + override fun onStart() { + super.onStart() + exitTransition = null + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun openFileManager() { + // First, try to open the user data folder directly + try { + startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) + return + } catch (_: ActivityNotFoundException) { + } + + try { + startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) + return + } catch (_: ActivityNotFoundException) { + } + + // Just try to open the file manager, try the package name used on "normal" phones + try { + startActivity(getFileManagerIntent("com.google.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + try { + // Next, try the AOSP package name + startActivity(getFileManagerIntent("com.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + Toast.makeText( + requireContext(), + resources.getString(R.string.no_file_manager), + Toast.LENGTH_LONG + ).show() + } + + private fun getFileManagerIntent(packageName: String): Intent { + // Fragile, but some phones don't expose the system file manager in any better way + val intent = Intent(Intent.ACTION_MAIN) + intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + return intent + } + + private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { + val authority = "${requireContext().packageName}.user" + val intent = Intent(action) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) + intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + return intent + } + + private fun showNoLinkNotification() { + val builder = NotificationCompat.Builder( + requireContext(), + getString(R.string.notice_notification_channel_id) + ) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.notification_no_directory_link)) + .setContentText(getString(R.string.notification_no_directory_link_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + // TODO: Make the click action for this notification lead to a help article + + with(NotificationManagerCompat.from(requireContext())) { + if (ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Toast.makeText( + requireContext(), + resources.getString(R.string.notification_permission_not_granted), + Toast.LENGTH_LONG + ).show() + return + } + notify(0, builder.build()) + } + } + + private fun driverInstaller() { + // Get the driver name for the dialog message. + var driverName = GpuDriverHelper.customDriverName + if (driverName == null) { + driverName = getString(R.string.system_gpu_driver) + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.select_gpu_driver_title)) + .setMessage(driverName) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int -> + GpuDriverHelper.installDefaultDriver(requireContext()) + Toast.makeText( + requireContext(), + R.string.select_gpu_driver_use_default, + Toast.LENGTH_SHORT + ).show() + } + .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int -> + mainActivity.getDriver.launch(arrayOf("application/zip")) + } + .show() + } + + 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 spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.scrollViewSettings.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + ) + + val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams + mlpScrollSettings.leftMargin = leftInsets + mlpScrollSettings.rightMargin = rightInsets + binding.scrollViewSettings.layoutParams = mlpScrollSettings + + binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) + + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) + } else { + binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) + } + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100755 index 000000000..3478b9250 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100755 index 000000000..1b4b93ab8 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt new file mode 100755 index 000000000..ebc0f164a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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 org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.Log +import java.util.Locale + +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?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.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 -> + if (text.toString().isNotEmpty()) { + binding.clearButton.visibility = View.VISIBLE + } else { + binding.clearButton.visibility = View.INVISIBLE + } + filterAndSearch() + } + + gamesViewModel.apply { + searchFocused.observe(viewLifecycleOwner) { searchFocused -> + if (searchFocused) { + focusSearch() + gamesViewModel.setSearchFocused(false) + } + } + + games.observe(viewLifecycleOwner) { filterAndSearch() } + searchedGames.observe(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noResultsView.visibility = View.VISIBLE + } else { + binding.noResultsView.visibility = View.GONE + } + } + } + + 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) + } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + } + } + + R.id.chip_homebrew -> { + baseList.filter { + Log.error("Guh - ${it.path}") + FileUtil.hasExtension(it.path, "nro") + || FileUtil.hasExtension(it.path, "nso") + } + } + + R.id.chip_retail -> baseList.filter { + FileUtil.hasExtension(it.path, "xci") + || FileUtil.hasExtension(it.path, "nsp") + } + + 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/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt new file mode 100755 index 000000000..258773380 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt @@ -0,0 +1,329 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback +import com.google.android.material.transition.MaterialFadeThrough +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.SetupAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.SetupPage +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.GameHelper +import java.io.File + +class SetupFragment : Fragment() { + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var mainActivity: MainActivity + + private lateinit var hasBeenWarned: BooleanArray + + companion object { + const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" + const val KEY_BACK_VISIBILITY = "BackButtonVisibility" + const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + exitTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSetupBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = false) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.viewPager2.currentItem > 0) { + pageBackward() + } else { + requireActivity().finish() + } + } + }) + + requireActivity().window.navigationBarColor = + ContextCompat.getColor(requireContext(), android.R.color.transparent) + + val pages = mutableListOf() + pages.apply { + add( + SetupPage( + R.drawable.ic_yuzu_title, + R.string.welcome, + R.string.welcome_description, + 0, + true, + R.string.get_started, + { pageForward() }, + false + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add( + SetupPage( + R.drawable.ic_notification, + R.string.notifications, + R.string.notifications_description, + 0, + false, + R.string.give_permission, + { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }, + true, + R.string.notification_warning, + R.string.notification_warning_description, + 0, + { + NotificationManagerCompat.from(requireContext()) + .areNotificationsEnabled() + } + ) + ) + } + + add( + SetupPage( + R.drawable.ic_key, + R.string.keys, + R.string.keys_description, + R.drawable.ic_add, + true, + R.string.select_keys, + { mainActivity.getProdKey.launch(arrayOf("*/*")) }, + true, + R.string.install_prod_keys_warning, + R.string.install_prod_keys_warning_description, + R.string.install_prod_keys_warning_help, + { File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() } + ) + ) + add( + SetupPage( + R.drawable.ic_controller, + R.string.games, + R.string.games_description, + R.drawable.ic_add, + true, + R.string.add_games, + { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + true, + R.string.add_games_warning, + R.string.add_games_warning_description, + R.string.add_games_warning_help, + { + val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty() + } + ) + ) + add( + SetupPage( + R.drawable.ic_check, + R.string.done, + R.string.done_description, + R.drawable.ic_arrow_forward, + false, + R.string.text_continue, + { finishSetup() }, + false + ) + ) + } + + binding.viewPager2.apply { + adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages) + offscreenPageLimit = 2 + isUserInputEnabled = false + } + + binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() { + var previousPosition: Int = 0 + + override fun onPageSelected(position: Int) { + super.onPageSelected(position) + + if (position == 1 && previousPosition == 0) { + showView(binding.buttonNext) + showView(binding.buttonBack) + } else if (position == 0 && previousPosition == 1) { + hideView(binding.buttonBack) + hideView(binding.buttonNext) + } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { + hideView(binding.buttonNext) + } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { + showView(binding.buttonNext) + } + + previousPosition = position + } + }) + + binding.buttonNext.setOnClickListener { + val index = binding.viewPager2.currentItem + val currentPage = pages[index] + + // Checks if the user has completed the task on the current page + if (currentPage.hasWarning) { + if (currentPage.taskCompleted.invoke()) { + pageForward() + return@setOnClickListener + } + + if (!hasBeenWarned[index]) { + SetupWarningDialogFragment.newInstance( + currentPage.warningTitleId, + currentPage.warningDescriptionId, + currentPage.warningHelpLinkId, + index + ).show(childFragmentManager, SetupWarningDialogFragment.TAG) + return@setOnClickListener + } + } + pageForward() + } + binding.buttonBack.setOnClickListener { pageBackward() } + + if (savedInstanceState != null) { + val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) + val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) + hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! + + if (nextIsVisible) { + binding.buttonNext.visibility = View.VISIBLE + } + if (backIsVisible) { + binding.buttonBack.visibility = View.VISIBLE + } + } else { + hasBeenWarned = BooleanArray(pages.size) + } + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) + outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) + outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + PermissionDeniedDialogFragment().show( + childFragmentManager, + PermissionDeniedDialogFragment.TAG + ) + } + } + + private fun finishSetup() { + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() + .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) + .apply() + mainActivity.finishSetup(binding.root.findNavController()) + } + + private fun showView(view: View) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = 300 + alpha(1f) + }.start() + } + + private fun hideView(view: View) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = 300 + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + } + } + + fun pageForward() { + binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 + } + + fun pageBackward() { + binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 + } + + fun setPageWarned(page: Int) { + hasBeenWarned[page] = true + } + + 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()) + view.setPadding( + barInsets.left + cutoutInsets.left, + barInsets.top + cutoutInsets.top, + barInsets.right + cutoutInsets.right, + barInsets.bottom + cutoutInsets.bottom + ) + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt new file mode 100755 index 000000000..b2c1d54af --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R + +class SetupWarningDialogFragment : DialogFragment() { + private var titleId: Int = 0 + private var descriptionId: Int = 0 + private var helpLinkId: Int = 0 + private var page: Int = 0 + + private lateinit var setupFragment: SetupFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + titleId = requireArguments().getInt(TITLE) + descriptionId = requireArguments().getInt(DESCRIPTION) + helpLinkId = requireArguments().getInt(HELP_LINK) + page = requireArguments().getInt(PAGE) + + setupFragment = requireParentFragment() as SetupFragment + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int -> + setupFragment.pageForward() + setupFragment.setPageWarned(page) + } + .setNegativeButton(R.string.warning_cancel, null) + + if (titleId != 0) { + builder.setTitle(titleId) + } else { + builder.setTitle("") + } + if (descriptionId != 0) { + builder.setMessage(descriptionId) + } + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int -> + val helpLink = resources.getString(R.string.install_prod_keys_warning_help) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink)) + startActivity(intent) + } + } + + return builder.show() + } + + companion object { + const val TAG = "SetupWarningDialogFragment" + + private const val TITLE = "Title" + private const val DESCRIPTION = "Description" + private const val HELP_LINK = "HelpLink" + private const val PAGE = "Page" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int, + page: Int + ): SetupWarningDialogFragment { + val dialog = SetupWarningDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE, titleId) + putInt(DESCRIPTION, descriptionId) + putInt(HELP_LINK, helpLinkId) + putInt(PAGE, page) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt new file mode 100755 index 000000000..be5e4c86c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.layout + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt new file mode 100755 index 000000000..2a17653b2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import java.util.HashSet + +@Parcelize +@Serializable +class Game( + val title: String, + val description: String, + val regions: String, + val path: String, + val gameId: String, + val company: String +) : Parcelable { + val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${gameId}_LastPlayed" + + override fun equals(other: Any?): Boolean { + if (other !is Game) + return false + + return title == other.title + && description == other.description + && regions == other.regions + && path == other.path + && gameId == other.gameId + && company == other.company + } + + companion object { + val extensions: Set = HashSet( + listOf(".xci", ".nsp", ".nca", ".nro") + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt new file mode 100755 index 000000000..7059856f1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.utils.GameHelper +import java.util.Locale + +class GamesViewModel : ViewModel() { + private val _games = MutableLiveData>(emptyList()) + val games: LiveData> get() = _games + + private val _searchedGames = MutableLiveData>(emptyList()) + val searchedGames: LiveData> get() = _searchedGames + + private val _isReloading = MutableLiveData(false) + val isReloading: LiveData get() = _isReloading + + private val _shouldSwapData = MutableLiveData(false) + val shouldSwapData: LiveData get() = _shouldSwapData + + private val _shouldScrollToTop = MutableLiveData(false) + val shouldScrollToTop: LiveData get() = _shouldScrollToTop + + private val _searchFocused = MutableLiveData(false) + val searchFocused: LiveData get() = _searchFocused + + init { + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + // Retrieve list of cached games + val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game = Json.decodeFromString(it) + val gameExists = + DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path)) + ?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + reloadGames(false) + } + + fun setGames(games: List) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.postValue(sortedList) + } + + fun setSearchedGames(games: List) { + _searchedGames.postValue(games) + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.postValue(shouldSwap) + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.postValue(shouldScroll) + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.postValue(searchFocused) + } + + fun reloadGames(directoryChanged: Boolean) { + if (isReloading.value == true) + return + _isReloading.postValue(true) + + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeLibrary.resetRomMetadata() + setGames(GameHelper.getGames()) + _isReloading.postValue(false) + + if (directoryChanged) { + setShouldSwapData(true) + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt new file mode 100755 index 000000000..7049f2fa5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +data class HomeSetting( + val titleId: Int, + val descriptionId: Int, + val iconId: Int, + val onClick: () -> Unit +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt new file mode 100755 index 000000000..263ee7144 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +class HomeViewModel : ViewModel() { + private val _navigationVisible = MutableLiveData>() + val navigationVisible: LiveData> get() = _navigationVisible + + private val _statusBarShadeVisible = MutableLiveData(true) + val statusBarShadeVisible: LiveData get() = _statusBarShadeVisible + + var navigatedToSetup = false + + init { + _navigationVisible.value = Pair(false, 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 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt new file mode 100755 index 000000000..b4b78e42d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import android.provider.DocumentsContract + +class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) { + val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt new file mode 100755 index 000000000..a0c878e1c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +data class SetupPage( + val iconId: Int, + val titleId: Int, + val descriptionId: Int, + val buttonIconId: Int, + val leftAlignedIcon: Boolean, + val buttonTextId: Int, + val buttonAction: () -> Unit, + val hasWarning: Boolean, + val warningTitleId: Int = 0, + val warningDescriptionId: Int = 0, + val warningHelpLinkId: Int = 0, + val taskCompleted: () -> Boolean = { true } +) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt new file mode 100755 index 000000000..f0b0af9e5 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt @@ -0,0 +1,1002 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.VectorDrawable +import android.os.Build +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import android.view.WindowInsets +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import androidx.window.layout.WindowMetricsCalculator +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.NativeLibrary.ButtonType +import org.yuzu.yuzu_emu.NativeLibrary.StickType +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.utils.EmulationMenuSettings +import kotlin.math.max +import kotlin.math.min + +/** + * Draws the interactive input overlay on top of the + * [SurfaceView] that is rendering emulation. + */ +class InputOverlay(context: Context, attrs: AttributeSet?) : SurfaceView(context, attrs), + OnTouchListener { + private val overlayButtons: MutableSet = HashSet() + private val overlayDpads: MutableSet = HashSet() + private val overlayJoysticks: MutableSet = HashSet() + + private var inEditMode = false + private var buttonBeingConfigured: InputOverlayDrawableButton? = null + private var dpadBeingConfigured: InputOverlayDrawableDpad? = null + private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null + + private val preferences: SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + private lateinit var windowInsets: WindowInsets + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + windowInsets = rootWindowInsets + + if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) { + defaultOverlay() + } + + // Load the controls. + refreshControls() + + // Set the on touch listener. + setOnTouchListener(this) + + // Force draw + setWillNotDraw(false) + + // Request focus for the overlay so it has priority on presses. + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + for (button in overlayButtons) { + button.draw(canvas) + } + for (dpad in overlayDpads) { + dpad.draw(canvas) + } + for (joystick in overlayJoysticks) { + joystick.draw(canvas) + } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (inEditMode) { + return onTouchWhileEditing(event) + } + + var shouldUpdateView = false + val playerIndex = + if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device + + for (button in overlayButtons) { + if (!button.updateStatus(event)) { + continue + } + NativeLibrary.onGamePadButtonEvent( + playerIndex, + button.buttonId, + button.status + ) + playHaptics(event) + shouldUpdateView = true + } + + for (dpad in overlayDpads) { + if (!dpad.updateStatus(event, EmulationMenuSettings.dpadSlide)) { + continue + } + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.upId, + dpad.upStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.downId, + dpad.downStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.leftId, + dpad.leftStatus + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + dpad.rightId, + dpad.rightStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + for (joystick in overlayJoysticks) { + if (!joystick.updateStatus(event)) { + continue + } + val axisID = joystick.joystickId + NativeLibrary.onGamePadJoystickEvent( + playerIndex, + axisID, + joystick.xAxis, + joystick.realYAxis + ) + NativeLibrary.onGamePadButtonEvent( + playerIndex, + joystick.buttonId, + joystick.buttonStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + if (shouldUpdateView) + invalidate() + + if (!preferences.getBoolean(Settings.PREF_TOUCH_ENABLED, true)) { + return true + } + + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionMove = motionEvent == MotionEvent.ACTION_MOVE + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) + } + + if (isActionMove) { + for (i in 0 until event.pointerCount) { + val fingerId = event.getPointerId(i) + if (isTouchInputConsumed(fingerId)) { + continue + } + NativeLibrary.onTouchMoved(fingerId, event.getX(i), event.getY(i)) + } + } + + if (isActionUp && !isTouchInputConsumed(pointerId)) { + NativeLibrary.onTouchReleased(pointerId) + } + + return true + } + + private fun playHaptics(event: MotionEvent) { + if (EmulationMenuSettings.hapticFeedback) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) + } + } + } + + private fun isTouchInputConsumed(track_id: Int): Boolean { + for (button in overlayButtons) { + if (button.trackId == track_id) { + return true + } + } + for (dpad in overlayDpads) { + if (dpad.trackId == track_id) { + return true + } + } + for (joystick in overlayJoysticks) { + if (joystick.trackId == track_id) { + return true + } + } + return false + } + + private fun onTouchWhileEditing(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + // TODO: Provide support for portrait layout + //val orientation = + // if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else "" + + for (button in overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + button.bounds.contains( + fingerPositionX, + fingerPositionY + ) + ) { + buttonBeingConfigured = button + buttonBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { + buttonBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) { + // Persist button position by saving new place. + saveControlPosition( + buttonBeingConfigured!!.buttonId, + buttonBeingConfigured!!.bounds.centerX(), + buttonBeingConfigured!!.bounds.centerY(), + "" + ) + buttonBeingConfigured = null + } + } + } + + for (dpad in overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + dpad.bounds.contains(fingerPositionX, fingerPositionY) + ) { + dpadBeingConfigured = dpad + dpadBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { + dpadBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) { + // Persist button position by saving new place. + saveControlPosition( + dpadBeingConfigured!!.upId, + dpadBeingConfigured!!.bounds.centerX(), + dpadBeingConfigured!!.bounds.centerY(), + "" + ) + dpadBeingConfigured = null + } + } + } + + for (joystick in overlayJoysticks) { + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && + joystick.bounds.contains( + fingerPositionX, + fingerPositionY + ) + ) { + joystickBeingConfigured = joystick + joystickBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { + joystickBeingConfigured!!.onConfigureTouch(event) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { + saveControlPosition( + joystickBeingConfigured!!.buttonId, + joystickBeingConfigured!!.bounds.centerX(), + joystickBeingConfigured!!.bounds.centerY(), + "" + ) + joystickBeingConfigured = null + } + } + } + + return true + } + + private fun addOverlayControls(orientation: String) { + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_0, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_a, + R.drawable.facebutton_a_depressed, + ButtonType.BUTTON_A, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_1, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_b, + R.drawable.facebutton_b_depressed, + ButtonType.BUTTON_B, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_2, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_x, + R.drawable.facebutton_x_depressed, + ButtonType.BUTTON_X, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_3, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_y, + R.drawable.facebutton_y_depressed, + ButtonType.BUTTON_Y, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_4, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.l_shoulder, + R.drawable.l_shoulder_depressed, + ButtonType.TRIGGER_L, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_5, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.r_shoulder, + R.drawable.r_shoulder_depressed, + ButtonType.TRIGGER_R, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_6, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.zl_trigger, + R.drawable.zl_trigger_depressed, + ButtonType.TRIGGER_ZL, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_7, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.zr_trigger, + R.drawable.zr_trigger_depressed, + ButtonType.TRIGGER_ZR, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_8, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_plus, + R.drawable.facebutton_plus_depressed, + ButtonType.BUTTON_PLUS, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_9, true)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_minus, + R.drawable.facebutton_minus_depressed, + ButtonType.BUTTON_MINUS, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_10, true)) { + overlayDpads.add( + initializeOverlayDpad( + context, + R.drawable.dpad_standard, + R.drawable.dpad_standard_cardinal_depressed, + R.drawable.dpad_standard_diagonal_depressed, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_11, true)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + StickType.STICK_L, + ButtonType.STICK_L, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_12, true)) { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + StickType.STICK_R, + ButtonType.STICK_R, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_13, false)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_home, + R.drawable.facebutton_home_depressed, + ButtonType.BUTTON_HOME, + orientation + ) + ) + } + if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_14, false)) { + overlayButtons.add( + initializeOverlayButton( + context, + R.drawable.facebutton_screenshot, + R.drawable.facebutton_screenshot_depressed, + ButtonType.BUTTON_CAPTURE, + orientation + ) + ) + } + } + + fun refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear() + overlayDpads.clear() + overlayJoysticks.clear() + val orientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else "" + + // Add all the enabled overlay items back to the HashSet. + if (EmulationMenuSettings.showOverlay) { + addOverlayControls(orientation) + } + invalidate() + } + + private fun saveControlPosition(sharedPrefsId: Int, x: Int, y: Int, orientation: String) { + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() + .putFloat("$sharedPrefsId$orientation-X", x.toFloat()) + .putFloat("$sharedPrefsId$orientation-Y", y.toFloat()) + .apply() + } + + fun setIsInEditMode(editMode: Boolean) { + inEditMode = editMode + } + + private fun defaultOverlay() { + if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) { + defaultOverlayLandscape() + } + + resetButtonPlacement() + preferences.edit() + .putBoolean(Settings.PREF_OVERLAY_INIT, true) + .apply() + } + + fun resetButtonPlacement() { + defaultOverlayLandscape() + refreshControls() + } + + private fun defaultOverlayLandscape() { + // Get screen size + val windowMetrics = + WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context as Activity) + var maxY = windowMetrics.bounds.height().toFloat() + var maxX = windowMetrics.bounds.width().toFloat() + var minY = 0 + var minX = 0 + + // If we have API access, calculate the safe area to draw the overlay + var cutoutLeft = 0 + var cutoutBottom = 0 + val insets = windowInsets.displayCutout + if (insets != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + maxY = + if (insets.boundingRectTop.bottom != 0) insets.boundingRectTop.bottom.toFloat() else maxY + maxX = + if (insets.boundingRectRight.left != 0) insets.boundingRectRight.left.toFloat() else maxX + minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left + minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom + + cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left + cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom + } + + // This makes sure that if we have an inset on one side of the screen, we mirror it on + // the other side. Since removing space from one of the max values messes with the scale, + // we also have to account for it using our min values. + if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft + if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom + if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) { + maxX -= (minX * 2) + } else if (minX > 0) { + maxX -= minX + } + if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) { + maxY -= (minY * 2) + } else if (minY > 0) { + maxY -= minY + } + + // Each value is a percent from max X/Y stored as an int. Have to bring that value down + // to a decimal before multiplying by MAX X/Y. + preferences.edit() + .putFloat( + ButtonType.BUTTON_A.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_A_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_A.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_A_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_B.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_B_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_B.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_B_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_X.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_X_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_X.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_X_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_Y.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_Y_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_Y.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_Y_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.TRIGGER_ZL.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.TRIGGER_ZL.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.TRIGGER_ZR.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.TRIGGER_ZR.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.DPAD_UP.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.DPAD_UP.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.TRIGGER_L.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_L_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.TRIGGER_L.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_L_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.TRIGGER_R.toString() + "-X", + resources.getInteger(R.integer.SWITCH_TRIGGER_R_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.TRIGGER_R.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_TRIGGER_R_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_PLUS.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_PLUS.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_MINUS.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_MINUS.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_HOME.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_HOME_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_HOME.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_HOME_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.BUTTON_CAPTURE.toString() + "-X", + resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_X) + .toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.BUTTON_CAPTURE.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_Y) + .toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.STICK_R.toString() + "-X", + resources.getInteger(R.integer.SWITCH_STICK_R_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.STICK_R.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_STICK_R_Y).toFloat() / 1000 * maxY + minY + ) + .putFloat( + ButtonType.STICK_L.toString() + "-X", + resources.getInteger(R.integer.SWITCH_STICK_L_X).toFloat() / 1000 * maxX + minX + ) + .putFloat( + ButtonType.STICK_L.toString() + "-Y", + resources.getInteger(R.integer.SWITCH_STICK_L_Y).toFloat() / 1000 * maxY + minY + ) + .apply() + } + + override fun isInEditMode(): Boolean { + return inEditMode + } + + companion object { + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context Context for getting the vector drawable + * @param drawableId The ID of the drawable to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { + val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable + + val bitmap = Bitmap.createBitmap( + (vectorDrawable.intrinsicWidth * scale).toInt(), + (vectorDrawable.intrinsicHeight * scale).toInt(), + Bitmap.Config.ARGB_8888 + ) + + val dm = context.resources.displayMetrics + val minScreenDimension = min(dm.widthPixels, dm.heightPixels) + + val maxBitmapDimension = max(bitmap.width, bitmap.height) + val bitmapScale = scale * minScreenDimension / maxBitmapDimension + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + (bitmap.width * bitmapScale).toInt(), + (bitmap.height * bitmapScale).toInt(), + true + ) + + val canvas = Canvas(scaledBitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return scaledBitmap + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + * + * + * This works due to the way the X and Y coordinates are stored within + * the [SharedPreferences]. + * + * + * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored + * within a SharedPreferences instance so that those values can be retrieved here. + * + * + * This has a few benefits over the conventional way of storing the values + * (ie. within the yuzu ini file). + * + * * No native calls + * * Keeps Android-only values inside the Android environment + * + * + * + * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current [Context]. + * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). + * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. + */ + private fun initializeOverlayButton( + context: Context, + defaultResId: Int, + pressedResId: Int, + buttonId: Int, + orientation: String + ): InputOverlayDrawableButton { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on button ID and user preference + var scale: Float = when (buttonId) { + ButtonType.BUTTON_HOME, + ButtonType.BUTTON_CAPTURE, + ButtonType.BUTTON_PLUS, + ButtonType.BUTTON_MINUS -> 0.07f + + ButtonType.TRIGGER_L, + ButtonType.TRIGGER_R, + ButtonType.TRIGGER_ZL, + ButtonType.TRIGGER_ZR -> 0.26f + + else -> 0.11f + } + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableButton. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedStateBitmap = getBitmap(context, pressedResId, scale) + val overlayDrawable = + InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId) + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val xKey = "$buttonId$orientation-X" + val yKey = "$buttonId$orientation-Y" + val drawableX = sPrefs.getFloat(xKey, 0f).toInt() + val drawableY = sPrefs.getFloat(yKey, 0f).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition( + drawableX - (width / 2), + drawableY - (height / 2) + ) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableDpad] + * + * @param context The current [Context]. + * @param defaultResId The [Bitmap] resource ID of the default state. + * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed state in one direction. + * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed state in two directions. + * @return the initialized [InputOverlayDrawableDpad] + */ + private fun initializeOverlayDpad( + context: Context, + defaultResId: Int, + pressedOneDirectionResId: Int, + pressedTwoDirectionsResId: Int, + orientation: String + ): InputOverlayDrawableDpad { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on button ID and user preference + var scale = 0.25f + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableDpad. + val defaultStateBitmap = + getBitmap(context, defaultResId, scale) + val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) + val pressedTwoDirectionsStateBitmap = + getBitmap(context, pressedTwoDirectionsResId, scale) + + val overlayDrawable = InputOverlayDrawableDpad( + res, + defaultStateBitmap, + pressedOneDirectionStateBitmap, + pressedTwoDirectionsStateBitmap, + ButtonType.DPAD_UP, + ButtonType.DPAD_DOWN, + ButtonType.DPAD_LEFT, + ButtonType.DPAD_RIGHT + ) + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-X", 0f).toInt() + val drawableY = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-Y", 0f).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX - (width / 2), drawableY - (height / 2)) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableJoystick] + * + * @param context The current [Context] + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @param button Identifier for which joystick button this is. + * @return the initialized [InputOverlayDrawableJoystick]. + */ + private fun initializeOverlayJoystick( + context: Context, + resOuter: Int, + defaultResInner: Int, + pressedResInner: Int, + joystick: Int, + button: Int, + orientation: String + ): InputOverlayDrawableJoystick { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick. + val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // Decide scale based on user preference + var scale = 0.3f + scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableJoystick. + val bitmapOuter = getBitmap(context, resOuter, scale) + val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f) + val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f) + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = sPrefs.getFloat("$button$orientation-X", 0f).toInt() + val drawableY = sPrefs.getFloat("$button$orientation-Y", 0f).toInt() + val outerScale = 1.66f + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + val outerSize = bitmapOuter.width + val outerRect = Rect( + drawableX - (outerSize / 2), + drawableY - (outerSize / 2), + drawableX + (outerSize / 2), + drawableY + (outerSize / 2) + ) + val innerRect = + Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) + + // Send the drawableId to the joystick so it can be referenced when saving control position. + val overlayDrawable = InputOverlayDrawableJoystick( + res, + bitmapOuter, + bitmapInnerDefault, + bitmapInnerPressed, + outerRect, + innerRect, + joystick, + button + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + return overlayDrawable + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt new file mode 100755 index 000000000..99d7d9521 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary.ButtonState + +/** + * 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 buttonId Identifier for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val buttonId: Int +) { + // 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) + } + + val status: Int + get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED + val bounds: Rect + get() = defaultStateBitmap.bounds +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100755 index 000000000..625cad661 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,267 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary.ButtonState + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] of the default state. + * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. + * @param buttonUp Identifier for the up button. + * @param buttonDown Identifier for the down button. + * @param buttonLeft Identifier for the left button. + * @param buttonRight Identifier for the right button. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap, + buttonUp: Int, + buttonDown: Int, + buttonLeft: Int, + buttonRight: Int +) { + /** + * 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 upId: Int + val downId: Int + val leftId: Int + val rightId: Int + 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 + upId = buttonUp + downId = buttonDown + leftId = buttonLeft + rightId = buttonRight + 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) + } + + val bounds: Rect + get() = defaultStateBitmap.bounds + + companion object { + const val VIRT_AXIS_DEADZONE = 0.5f + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt new file mode 100755 index 000000000..1960eaff0 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.EmulationMenuSettings +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. + * @param rectOuter [Rect] which represents the outer joystick bounds. + * @param rectInner [Rect] which represents the inner joystick bounds. + * @param joystickId The ID value what type of joystick this Drawable represents. + * @param buttonId The ID value what type of button this Drawable represents. + */ +class InputOverlayDrawableJoystick( + res: Resources, + bitmapOuter: Bitmap, + bitmapInnerDefault: Bitmap, + bitmapInnerPressed: Bitmap, + rectOuter: Rect, + rectInner: Rect, + val joystickId: Int, + val buttonId: Int +) { + // The ID value what motion event is tracking + var trackId = -1 + + var xAxis = 0f + private var yAxis = 0f + + val width: Int + val height: Int + + private var virtBounds: Rect + private var origBounds: Rect + + private val outerBitmap: BitmapDrawable + private val defaultStateInnerBitmap: BitmapDrawable + private val pressedStateInnerBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + private val boundsBoxBitmap: BitmapDrawable + + private var pressedState = false + + // TODO: Add button support + val buttonStatus: Int + get() = + NativeLibrary.ButtonState.RELEASED + var bounds: Rect + get() = outerBitmap.bounds + set(bounds) { + outerBitmap.bounds = bounds + } + + // Nintendo joysticks have y axis inverted + val realYAxis: Float + get() = -yAxis + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap + + init { + outerBitmap = BitmapDrawable(res, bitmapOuter) + defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) + pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) + boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) + width = bitmapOuter.width + height = bitmapOuter.height + bounds = rectOuter + defaultStateInnerBitmap.bounds = rectInner + pressedStateInnerBitmap.bounds = rectInner + virtBounds = bounds + origBounds = outerBitmap.copyBounds() + boundsBoxBitmap.alpha = 0 + boundsBoxBitmap.bounds = virtBounds + setInnerBounds() + } + + fun draw(canvas: Canvas?) { + outerBitmap.draw(canvas!!) + currentStateBitmapDrawable.draw(canvas) + boundsBoxBitmap.draw(canvas) + } + + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = 255 + if (EmulationMenuSettings.joystickRelCenter) { + virtBounds.offset( + xPosition - virtBounds.centerX(), + yPosition - virtBounds.centerY() + ) + } + boundsBoxBitmap.bounds = virtBounds + trackId = pointerId + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + xAxis = 0.0f + yAxis = 0.0f + outerBitmap.alpha = 255 + boundsBoxBitmap.alpha = 0 + virtBounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + bounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + setInnerBounds() + trackId = -1 + return true + } + + if (trackId == -1) return false + + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = virtBounds.bottom.toFloat() + var maxX = virtBounds.right.toFloat() + touchX -= virtBounds.centerX().toFloat() + maxX -= virtBounds.centerX().toFloat() + touchY -= virtBounds.centerY().toFloat() + maxY -= virtBounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldXAxis = xAxis + val oldYAxis = yAxis + + // Clamp the circle pad input to a circle + val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat() + var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat() + if (radius > 1.0f) { + radius = 1.0f + } + xAxis = cos(angle.toDouble()).toFloat() * radius + yAxis = sin(angle.toDouble()).toFloat() * radius + setInnerBounds() + return oldXAxis != xAxis && oldYAxis != yAxis + } + return false + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + bounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + virtBounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + setInnerBounds() + bounds = Rect( + Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + origBounds = outerBitmap.copyBounds() + return true + } + + private fun setInnerBounds() { + var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() + var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() + if (x > virtBounds.centerX() + virtBounds.width() / 2) x = + virtBounds.centerX() + virtBounds.width() / 2 + if (x < virtBounds.centerX() - virtBounds.width() / 2) x = + virtBounds.centerX() - virtBounds.width() / 2 + if (y > virtBounds.centerY() + virtBounds.height() / 2) y = + virtBounds.centerY() + virtBounds.height() / 2 + if (y < virtBounds.centerY() - virtBounds.height() / 2) y = + virtBounds.centerY() - virtBounds.height() / 2 + val width = pressedStateInnerBitmap.bounds.width() / 2 + val height = pressedStateInnerBitmap.bounds.height() / 2 + defaultStateInnerBitmap.setBounds( + x - width, + y - height, + x + width, + y + height + ) + pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt new file mode 100755 index 000000000..97eef40d2 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +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 com.google.android.material.color.MaterialColors +import com.google.android.material.transition.MaterialFadeThrough +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel + +class GamesFragment : Fragment() { + private var _binding: FragmentGamesBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamesBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = true, animated = false) + + binding.gridGames.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.swipeRefresh.apply { + // Add swipe down to refresh gesture + setOnRefreshListener { + gamesViewModel.reloadGames(false) + } + + // Set theme color to the refresh animation's background + setProgressBackgroundColorSchemeColor( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorPrimary + ) + ) + setColorSchemeColors( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorOnPrimary + ) + ) + + // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn + post { + if (_binding == null) { + return@post + } + binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!! + } + } + + gamesViewModel.apply { + // Watch for when we get updates to any of our games lists + isReloading.observe(viewLifecycleOwner) { isReloading -> + binding.swipeRefresh.isRefreshing = isReloading + } + games.observe(viewLifecycleOwner) { + (binding.gridGames.adapter as GameAdapter).submitList(it) + if (it.isEmpty()) { + binding.noticeText.visibility = View.VISIBLE + } else { + binding.noticeText.visibility = View.GONE + } + } + shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData -> + if (shouldSwapData) { + (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!) + gamesViewModel.setShouldSwapData(false) + } + } + + // Check if the user reselected the games menu item and then scroll to top of the list + shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll -> + if (shouldScroll) { + scrollToTop() + gamesViewModel.setShouldScrollToTop(false) + } + } + } + + setInsets() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun scrollToTop() { + if (_binding != null) { + binding.gridGames.smoothScrollToPosition(0) + } + } + + 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_large) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + binding.gridGames.updatePadding( + top = barInsets.top + extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + + binding.swipeRefresh.setProgressViewEndTarget( + false, + barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) + ) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + mlpSwipe.leftMargin = leftInsets + spacingNavigationRail + mlpSwipe.rightMargin = rightInsets + } else { + mlpSwipe.leftMargin = leftInsets + mlpSwipe.rightMargin = rightInsets + spacingNavigationRail + } + binding.swipeRefresh.layoutParams = mlpSwipe + + binding.noticeText.updatePadding(bottom = spacingNavigation) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt new file mode 100755 index 000000000..b1329db74 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt @@ -0,0 +1,418 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui.main + +import android.content.Intent +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.lifecycle.lifecycleScope +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.dialog.MaterialAlertDialogBuilder +import com.google.android.material.navigation.NavigationBarView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.databinding.ActivityMainBinding +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.* +import java.io.IOException + +class MainActivity : AppCompatActivity(), ThemeProvider { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + + override var themeId: Int = 0 + + 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) + + 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 -> SettingsActivity.launch( + this, + SettingsFile.FILE_NAME_CONFIG, + "" + ) + } + } + + // Prevents navigation from being drawn for a short time on recreation if set to hidden + if (!homeViewModel.navigationVisible.value?.first!!) { + binding.navigationView.visibility = View.INVISIBLE + binding.statusBarShade.visibility = View.INVISIBLE + } + + homeViewModel.navigationVisible.observe(this) { + showNavigation(it.first, it.second) + } + homeViewModel.statusBarShadeVisible.observe(this) { visible -> + showStatusBarShade(visible) + } + + // Dismiss previous notifications (should not happen unless a crash occurred) + EmulationActivity.stopForegroundService(this) + + setInsets() + } + + 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) { + if (visible) { + binding.navigationView.visibility = View.VISIBLE + } else { + binding.navigationView.visibility = View.INVISIBLE + } + return + } + + val smallLayout = resources.getBoolean(R.bool.small_layout) + binding.navigationView.animate().apply { + if (visible) { + binding.navigationView.visibility = View.VISIBLE + 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.visibility = View.INVISIBLE + } + }.start() + } + + private fun showStatusBarShade(visible: Boolean) { + binding.statusBarShade.animate().apply { + if (visible) { + binding.statusBarShade.visibility = View.VISIBLE + 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.visibility = View.INVISIBLE + } + }.start() + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + override fun onDestroy() { + EmulationActivity.stopForegroundService(this) + super.onDestroy() + } + + 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) + return@registerForActivityResult + + val takeFlags = + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission( + result, + takeFlags + ) + + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + PreferenceManager.getDefaultSharedPreferences(applicationContext).edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() + + Toast.makeText( + applicationContext, + R.string.games_dir_selected, + Toast.LENGTH_LONG + ).show() + + gamesViewModel.reloadGames(true) + } + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + if (!FileUtil.hasExtension(result.toString(), "keys")) { + Toast.makeText( + applicationContext, + R.string.invalid_keys_file, + Toast.LENGTH_SHORT + ).show() + return@registerForActivityResult + } + + val takeFlags = + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission( + result, + takeFlags + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + applicationContext, + result, + dstPath, + "prod.keys" + ) + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + gamesViewModel.reloadGames(true) + } else { + Toast.makeText( + applicationContext, + R.string.install_keys_failure, + Toast.LENGTH_LONG + ).show() + } + } + } + + val getAmiiboKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + if (!FileUtil.hasExtension(result.toString(), "bin")) { + Toast.makeText( + applicationContext, + R.string.invalid_keys_file, + Toast.LENGTH_SHORT + ).show() + return@registerForActivityResult + } + + val takeFlags = + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission( + result, + takeFlags + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + applicationContext, + result, + dstPath, + "key_retail.bin" + ) + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + applicationContext, + R.string.install_amiibo_keys_failure, + Toast.LENGTH_LONG + ).show() + } + } + } + + val getDriver = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) + return@registerForActivityResult + + val takeFlags = + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + contentResolver.takePersistableUriPermission( + result, + takeFlags + ) + + val progressBinding = DialogProgressBarBinding.inflate(layoutInflater) + progressBinding.progressBar.isIndeterminate = true + val installationDialog = MaterialAlertDialogBuilder(this) + .setTitle(R.string.installing_driver) + .setView(progressBinding.root) + .show() + + lifecycleScope.launch { + withContext(Dispatchers.IO) { + // Ignore file exceptions when a user selects an invalid zip + try { + GpuDriverHelper.installCustomDriver(applicationContext, result) + } catch (_: IOException) { + } + + withContext(Dispatchers.Main) { + installationDialog.dismiss() + + val driverName = GpuDriverHelper.customDriverName + if (driverName != null) { + Toast.makeText( + applicationContext, + getString( + R.string.select_gpu_driver_install_success, + driverName + ), + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + applicationContext, + R.string.select_gpu_driver_error, + Toast.LENGTH_LONG + ).show() + } + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt new file mode 100755 index 000000000..511a6e4fa --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt new file mode 100755 index 000000000..9cfda74ee --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +class BiMap { + private val forward: MutableMap = HashMap() + private val backward: MutableMap = HashMap() + + @Synchronized + fun add(key: K, value: V) { + forward[key] = value + backward[value] = key + } + + @Synchronized + fun getForward(key: K): V? { + return forward[key] + } + + @Synchronized + fun getBackward(key: V): K? { + return backward[key] + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt new file mode 100755 index 000000000..791cea904 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +/** + * Some controllers have incorrect mappings. This class has special-case fixes for them. + */ +class ControllerMappingHelper { + /** + * Some controllers report extra button presses that can be ignored. + */ + fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean { + return if (isDualShock4(inputDevice)) { + // The two analog triggers generate analog motion events as well as a keycode. + // We always prefer to use the analog values, so throw away the button press + keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2 + } else false + } + + /** + * Scale an axis to be zero-centered with a proper range. + */ + fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float { + if (isDualShock4(inputDevice)) { + // Android doesn't have correct mappings for this controller's triggers. It reports them + // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0] + // Scale them to properly zero-centered with a range of [0.0, 1.0]. + if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) { + return (value + 1) / 2.0f + } + } else if (isXboxOneWireless(inputDevice)) { + // Same as the DualShock 4, the mappings are missing. + if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) { + return (value + 1) / 2.0f + } + if (axis == MotionEvent.AXIS_GENERIC_1) { + // This axis is stuck at ~.5. Ignore it. + return 0.0f + } + } else if (isMogaPro2Hid(inputDevice)) { + // This controller has a broken axis that reports a constant value. Ignore it. + if (axis == MotionEvent.AXIS_GENERIC_1) { + return 0.0f + } + } + return value + } + + // Sony DualShock 4 controller + private fun isDualShock4(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc + } + + // Microsoft Xbox One controller + private fun isXboxOneWireless(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0 + } + + // Moga Pro 2 HID + private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean { + return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271 + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt new file mode 100755 index 000000000..36c479e6c --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.IOException + +object DirectoryInitialization { + private var userPath: String? = null + + var areDirectoriesReady: Boolean = false + + fun start(context: Context) { + if (!areDirectoriesReady) { + initializeInternalStorage(context) + NativeLibrary.initializeEmulation() + areDirectoriesReady = true + } + } + + val userDirectory: String? + get() { + check(areDirectoriesReady) { "Directory initialization is not ready!" } + return userPath + } + + private fun initializeInternalStorage(context: Context) { + try { + userPath = context.getExternalFilesDir(null)!!.canonicalPath + NativeLibrary.setAppDirectory(userPath!!) + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt new file mode 100755 index 000000000..cc8ea6b9d --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import java.io.File +import java.util.* + +class DocumentsTree { + private var root: DocumentsNode? = null + + fun setRoot(rootUri: Uri?) { + root = null + root = DocumentsNode() + root!!.uri = rootUri + root!!.isDirectory = true + } + + fun openContentUri(filepath: String, openMode: String?): Int { + val node = resolvePath(filepath) ?: return -1 + return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode) + } + + fun getFileSize(filepath: String): Long { + val node = resolvePath(filepath) + return if (node == null || node.isDirectory) { + 0 + } else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString()) + } + + fun exists(filepath: String): Boolean { + return resolvePath(filepath) != null + } + + private fun resolvePath(filepath: String): DocumentsNode? { + val tokens = StringTokenizer(filepath, File.separator, false) + var iterator = root + while (tokens.hasMoreTokens()) { + val token = tokens.nextToken() + if (token.isEmpty()) continue + iterator = find(iterator, token) + if (iterator == null) return null + } + return iterator + } + + private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? { + if (parent!!.isDirectory && !parent.loaded) { + structTree(parent) + } + return parent.children[filename] + } + + /** + * Construct current level directory tree + * @param parent parent node of this level + */ + private fun structTree(parent: DocumentsNode) { + val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!) + for (document in documents) { + val node = DocumentsNode(document) + node.parent = parent + parent.children[node.name] = node + } + parent.loaded = true + } + + private class DocumentsNode { + var parent: DocumentsNode? = null + val children: MutableMap = HashMap() + var name: String? = null + var uri: Uri? = null + var loaded = false + var isDirectory = false + + constructor() + constructor(document: MinimalDocumentFile) { + name = document.filename + uri = document.uri + isDirectory = document.isDirectory + loaded = !isDirectory + } + + private constructor(document: DocumentFile, isCreateDir: Boolean) { + name = document.name + uri = document.uri + isDirectory = isCreateDir + loaded = true + } + + private fun rename(name: String) { + if (parent == null) { + return + } + parent!!.children.remove(this.name) + this.name = name + parent!!.children[name] = this + } + } + + companion object { + fun isNativePath(path: String): Boolean { + return if (path.isNotEmpty()) { + path[0] == '/' + } else false + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt new file mode 100755 index 000000000..e1e7a59d7 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import androidx.preference.PreferenceManager +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings + +object EmulationMenuSettings { + private val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + // These must match what is defined in src/core/settings.h + const val LayoutOption_Default = 0 + const val LayoutOption_SingleScreen = 1 + const val LayoutOption_LargeScreen = 2 + const val LayoutOption_SideScreen = 3 + const val LayoutOption_MobilePortrait = 4 + const val LayoutOption_MobileLandscape = 5 + + var joystickRelCenter: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value) + .apply() + } + var dpadSlide: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value) + .apply() + } + var hapticFeedback: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value) + .apply() + } + + var landscapeScreenLayout: Int + get() = preferences.getInt( + Settings.PREF_MENU_SETTINGS_LANDSCAPE, + LayoutOption_MobileLandscape + ) + set(value) { + preferences.edit() + .putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value) + .apply() + } + var showFps: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value) + .apply() + } + var showOverlay: Boolean + get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true) + set(value) { + preferences.edit() + .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value) + .apply() + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt new file mode 100755 index 000000000..0a7b323b1 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt @@ -0,0 +1,298 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.net.URLDecoder + +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" + + /** + * 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(context: Context?, 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 context Application context + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + fun createDir(context: Context?, 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 context Application context + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(context: Context, 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 DoucmentFile.listFiles + * @param context Application context + * @param uri Directory uri. + * @return CheapDocument lists. + */ + fun listFiles(context: Context, 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(context: Context, path: String?): 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) { + 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(context: Context, 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 path content uri path + * @return String display name + */ + fun getFilename(context: Context, path: String): String { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + var filename = "" + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, 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(context: Context, path: String): Array { + val uri = Uri.parse(path) + val files: MutableList = ArrayList() + for (file in listFiles(context, 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(context: Context, 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 + } + + fun copyUriToInternalStorage( + context: Context, + sourceUri: Uri?, + destinationParentPath: String, + destinationFilename: String + ): Boolean { + var input: InputStream? = null + var output: FileOutputStream? = null + try { + input = context.contentResolver.openInputStream(sourceUri!!) + output = FileOutputStream("$destinationParentPath/$destinationFilename") + val buffer = ByteArray(1024) + var len: Int + while (input!!.read(buffer).also { len = it } != -1) { + output.write(buffer, 0, len) + } + output.flush() + return true + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot copy file, error: " + e.message) + } finally { + if (input != null) { + try { + input.close() + } catch (e: IOException) { + Log.error("[FileUtil]: Cannot close input file, error: " + e.message) + } + } + if (output != null) { + try { + output.close() + } catch (e: IOException) { + Log.error("[FileUtil]: Cannot close output file, error: " + e.message) + } + } + } + return false + } + + 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 hasExtension(path: String, extension: String): Boolean { + return path.substring(path.lastIndexOf(".") + 1).contains(extension) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt new file mode 100755 index 000000000..626123966 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity + +/** + * A service that shows a permanent notification in the background to avoid the app getting + * cleared from memory by the system. + */ +class ForegroundService : Service() { + companion object { + const val EMULATION_RUNNING_NOTIFICATION = 0x1000 + + const val ACTION_STOP = "stop" + } + + private fun showRunningNotification() { + // Intent is used to resume emulation if the notification is clicked + val contentIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, EmulationActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + val builder = + NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id)) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.emulation_notification_running)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setVibrate(null) + .setSound(null) + .setContentIntent(contentIntent) + startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build()) + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + override fun onCreate() { + showRunningNotification() + } + + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent.action == ACTION_STOP) { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelfResult(startId) + } + return START_STICKY + } + + override fun onDestroy() { + NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt new file mode 100755 index 000000000..ba6b5783e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.Game +import java.util.* + +object GameHelper { + const val KEY_GAME_PATH = "game_path" + const val KEY_GAMES = "Games" + + private lateinit var preferences: SharedPreferences + + fun getGames(): List { + val games = mutableListOf() + val context = YuzuApplication.appContext + val gamesDir = + PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "") + val gamesUri = Uri.parse(gamesDir) + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + val children = FileUtil.listFiles(context, gamesUri) + for (file in children) { + if (!file.isDirectory) { + val filename = file.uri.toString() + val extensionStart = filename.lastIndexOf('.') + if (extensionStart > 0) { + val fileExtension = filename.substring(extensionStart) + + // Check that the file has an extension we care about before trying to read out of it. + if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) { + games.add(getGame(filename)) + } + } + } + } + + // 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 getGame(filePath: String): Game { + var name = NativeLibrary.getTitle(filePath) + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = filePath.substring(filePath.lastIndexOf("/") + 1) + } + var gameId = NativeLibrary.getGameId(filePath) + + // If the game's ID field is empty, use the filename without extension. + if (gameId.isEmpty()) { + gameId = filePath.substring( + filePath.lastIndexOf("/") + 1, + filePath.lastIndexOf(".") + ) + } + + val newGame = Game( + name, + NativeLibrary.getDescription(filePath).replace("\n", " "), + NativeLibrary.getRegions(filePath), + filePath, + gameId, + NativeLibrary.getCompany(filePath) + ) + + 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/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt new file mode 100755 index 000000000..ed3c0f58e --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Context +import android.net.Uri +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.util.zip.ZipInputStream + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip" + private var fileRedirectionPath: String? = null + private var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + @Throws(IOException::class) + private fun unzip(zipFilePath: String, destDir: String) { + val dir = File(destDir) + + // Create output directory if it doesn't exist + if (!dir.exists()) dir.mkdirs() + + // Unpack the files. + val inputStream = FileInputStream(zipFilePath) + val zis = ZipInputStream(BufferedInputStream(inputStream)) + val buffer = ByteArray(1024) + var ze = zis.nextEntry + while (ze != null) { + val newFile = File(destDir, ze.name) + val canonicalPath = newFile.canonicalPath + if (!canonicalPath.startsWith(destDir + ze.name)) { + throw SecurityException("Zip file attempted path traversal! " + ze.name) + } + + newFile.parentFile!!.mkdirs() + val fos = FileOutputStream(newFile) + var len: Int + while (zis.read(buffer).also { len = it } > 0) { + fos.write(buffer, 0, len) + } + fos.close() + zis.closeEntry() + ze = zis.nextEntry + } + zis.closeEntry() + } + + fun initializeDriverParameters(context: Context) { + try { + // Initialize the file redirection directory. + fileRedirectionPath = + context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = context.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + customDriverLibraryName, + fileRedirectionPath + ) + } + + fun installDefaultDriver(context: Context) { + // Removing the installed driver will result in the backend using the default system driver. + val driverInstallationDir = File(driverInstallationPath!!) + deleteRecursive(driverInstallationDir) + initializeDriverParameters(context) + } + + fun installCustomDriver(context: Context, driverPathUri: Uri?) { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver(context) + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI into our private storage. + copyUriToInternalStorage( + context, + driverPathUri, + driverInstallationPath!!, + DRIVER_INTERNAL_FILENAME + ) + + // Unzip the driver. + unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!) + + // Initialize the driver parameters. + initializeDriverParameters(context) + } + + // Parse the custom driver metadata to retrieve the name. + val customDriverName: String? + get() { + val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) + return metadata.name + } + + // Parse the custom driver metadata to retrieve the library name. + private val customDriverLibraryName: String? + get() { + // Parse the custom driver metadata to retrieve the library name. + val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME) + return metadata.libraryName + } + + private 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() + } + } + + private fun deleteRecursive(fileOrDirectory: File) { + if (fileOrDirectory.isDirectory) { + for (child in fileOrDirectory.listFiles()!!) { + deleteRecursive(child) + } + } + fileOrDirectory.delete() + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt new file mode 100755 index 000000000..70bdb4097 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Paths + +class GpuDriverMetadata(metadataFilePath: String) { + var name: String? = null + var description: String? = null + var author: String? = null + var vendor: String? = null + var driverVersion: String? = null + var minApi = 0 + var libraryName: String? = null + + init { + try { + val json = JSONObject(getStringFromFile(metadataFilePath)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + driverVersion = json.getString("driverVersion") + minApi = json.getInt("minApi") + libraryName = json.getString("libraryName") + } catch (e: JSONException) { + // JSON is malformed, ignore and treat as unsupported metadata. + } catch (e: IOException) { + // File is inaccessible, ignore and treat as unsupported metadata. + } + } + + companion object { + @Throws(IOException::class) + private fun getStringFromFile(filePath: String): String { + val path = Paths.get(filePath) + val bytes = Files.readAllBytes(path) + return String(bytes, StandardCharsets.UTF_8) + } + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt new file mode 100755 index 000000000..24e999b29 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.view.KeyEvent +import android.view.MotionEvent +import org.yuzu.yuzu_emu.NativeLibrary +import kotlin.math.sqrt + +class InputHandler { + fun initialize() { + // Connect first controller + NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device)) + } + + fun dispatchKeyEvent(event: KeyEvent): Boolean { + val button: Int = when (event.device.vendorId) { + 0x045E -> getInputXboxButtonKey(event.keyCode) + 0x054C -> getInputDS5ButtonKey(event.keyCode) + 0x057E -> getInputJoyconButtonKey(event.keyCode) + 0x1532 -> getInputRazerButtonKey(event.keyCode) + else -> getInputGenericButtonKey(event.keyCode) + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED + else -> return false + } + + // Ignore invalid buttons + if (button < 0) { + return false + } + + return NativeLibrary.onGamePadButtonEvent( + getPlayerNumber(event.device.controllerNumber), + button, + action + ) + } + + fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val device = event.device + // Check every axis input available on the controller + for (range in device.motionRanges) { + val axis = range.axis + when (device.vendorId) { + 0x045E -> setGenericAxisInput(event, axis) + 0x054C -> setGenericAxisInput(event, axis) + 0x057E -> setJoyconAxisInput(event, axis) + 0x1532 -> setRazerAxisInput(event, axis) + else -> setGenericAxisInput(event, axis) + } + } + + return true + } + + private fun getPlayerNumber(index: Int): Int { + // TODO: Joycons are handled as different controllers. Find a way to merge them. + return when (index) { + 2 -> NativeLibrary.Player2Device + 3 -> NativeLibrary.Player3Device + 4 -> NativeLibrary.Player4Device + 5 -> NativeLibrary.Player5Device + 6 -> NativeLibrary.Player6Device + 7 -> NativeLibrary.Player7Device + 8 -> NativeLibrary.Player8Device + else -> if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device + } + } + + private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) { + // Calculate vector size + val r2 = xAxis * xAxis + yAxis * yAxis + var r = sqrt(r2.toDouble()).toFloat() + + // Adjust range of joystick + val deadzone = 0.15f + var x = xAxis + var y = yAxis + + if (r > deadzone) { + val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone) + x *= deadzoneFactor + y *= deadzoneFactor + r *= deadzoneFactor + } else { + x = 0.0f + y = 0.0f + } + + // Normalize joystick + if (r > 1.0f) { + x /= r + y /= r + } + + NativeLibrary.onGamePadJoystickEvent( + playerNumber, + index, + x, + -y + ) + } + + private fun getAxisToButton(axis: Float): Int { + return if (axis > 0.5f) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED + } + + private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) { + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_UP, + getAxisToButton(-yAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_DOWN, + getAxisToButton(yAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_LEFT, + getAxisToButton(-xAxis) + ) + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.DPAD_RIGHT, + getAxisToButton(xAxis) + ) + } + + private fun getInputDS5ButtonKey(key: Int): Int { + // The missing ds5 buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputJoyconButtonKey(key: Int): Int { + // Joycon support is half dead. A lot of buttons can't be mapped + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL + KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputXboxButtonKey(key: Int): Int { + // The missing xbox buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputRazerButtonKey(key: Int): Int { + // The missing xbox buttons are axis + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun getInputGenericButtonKey(key: Int): Int { + return when (key) { + KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A + KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B + KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X + KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y + KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP + KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN + KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT + KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT + KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L + KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R + KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL + KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR + KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L + KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R + KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS + KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS + else -> -1 + } + } + + private fun setGenericAxisInput(event: MotionEvent, axis: Int) { + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_RX), + event.getAxisValue(MotionEvent.AXIS_RY) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_LTRIGGER -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER)) + ) + MotionEvent.AXIS_BRAKE -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) + ) + MotionEvent.AXIS_RTRIGGER -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER)) + ) + MotionEvent.AXIS_GAS -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) + ) + MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> + setAxisDpadState( + playerNumber, + event.getAxisValue(MotionEvent.AXIS_HAT_X), + event.getAxisValue(MotionEvent.AXIS_HAT_Y) + ) + } + } + + + private fun setJoyconAxisInput(event: MotionEvent, axis: Int) { + // Joycon support is half dead. Right joystick doesn't work + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_RX, MotionEvent.AXIS_RY -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_RX), + event.getAxisValue(MotionEvent.AXIS_RY) + ) + } + } + + private fun setRazerAxisInput(event: MotionEvent, axis: Int) { + val playerNumber = getPlayerNumber(event.device.controllerNumber) + + when (axis) { + MotionEvent.AXIS_X, MotionEvent.AXIS_Y -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_L, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y) + ) + MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ -> + setStickState( + playerNumber, + NativeLibrary.StickType.STICK_R, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ) + ) + MotionEvent.AXIS_BRAKE -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZL, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE)) + ) + MotionEvent.AXIS_GAS -> + NativeLibrary.onGamePadButtonEvent( + playerNumber, + NativeLibrary.ButtonType.TRIGGER_ZR, + getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS)) + ) + MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y -> + setAxisDpadState( + playerNumber, + event.getAxisValue(MotionEvent.AXIS_HAT_X), + event.getAxisValue(MotionEvent.AXIS_HAT_Y) + ) + } + } + + +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt new file mode 100755 index 000000000..19c53c481 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.graphics.Rect + +object InsetsHelper { + const val THREE_BUTTON_NAVIGATION = 0 + const val TWO_BUTTON_NAVIGATION = 1 + const val GESTURE_NAVIGATION = 2 + + @SuppressLint("DiscouragedApi") + fun getSystemGestureType(context: Context): Int { + val resources = context.resources + val resourceId = + resources.getIdentifier("config_navBarInteractionMode", "integer", "android") + return if (resourceId != 0) { + resources.getInteger(resourceId) + } else 0 + } + + fun getBottomPaddingRequired(activity: Activity): Int { + val visibleFrame = Rect() + activity.window.decorView.getWindowVisibleDisplayFrame(visibleFrame) + return visibleFrame.bottom - visibleFrame.top - activity.resources.displayMetrics.heightPixels + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt new file mode 100755 index 000000000..a193e82a4 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.util.Log +import org.yuzu.yuzu_emu.BuildConfig + +/** + * Contains methods that call through to [android.util.Log], but + * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log + * levels in release builds. + */ +object Log { + private const val TAG = "Yuzu Frontend" + + fun verbose(message: String) { + if (BuildConfig.DEBUG) { + Log.v(TAG, message) + } + } + + fun debug(message: String) { + if (BuildConfig.DEBUG) { + Log.d(TAG, message) + } + } + + fun info(message: String) { + Log.i(TAG, message) + } + + fun warning(message: String) { + Log.w(TAG, message) + } + + fun error(message: String) { + Log.e(TAG, message) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt new file mode 100755 index 000000000..344dd8a0a --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentFilter +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.NfcA +import android.os.Build +import android.os.Handler +import android.os.Looper +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.IOException + +class NfcReader(private val activity: Activity) { + private var nfcAdapter: NfcAdapter? = null + private var pendingIntent: PendingIntent? = null + + fun initialize() { + nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return + + pendingIntent = PendingIntent.getActivity( + activity, + 0, Intent(activity, activity.javaClass), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + else PendingIntent.FLAG_UPDATE_CURRENT + ) + + val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + tagDetected.addCategory(Intent.CATEGORY_DEFAULT) + } + + fun startScanning() { + nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null) + } + + fun stopScanning() { + nfcAdapter?.disableForegroundDispatch(activity) + } + + fun onNewIntent(intent: Intent) { + val action = intent.action + if (NfcAdapter.ACTION_TAG_DISCOVERED != action + && NfcAdapter.ACTION_TECH_DISCOVERED != action + && NfcAdapter.ACTION_NDEF_DISCOVERED != action + ) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return + readTagData(tag) + return + } + + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return + readTagData(tag) + } + + private fun readTagData(tag: Tag) { + if (!tag.techList.contains("android.nfc.tech.NfcA")) { + return + } + + val amiibo = NfcA.get(tag) ?: return + amiibo.connect() + + val tagData = ntag215ReadAll(amiibo) ?: return + NativeLibrary.onReadNfcTag(tagData) + + nfcAdapter?.ignore( + tag, + 1000, + { NativeLibrary.onRemoveNfcTag() }, + Handler(Looper.getMainLooper()) + ) + } + + private fun ntag215ReadAll(amiibo: NfcA): ByteArray? { + val bufferSize = amiibo.maxTransceiveLength; + val tagSize = 0x21C + val pageSize = 4 + val lastPage = tagSize / pageSize - 1 + val tagData = ByteArray(tagSize) + + // We need to read the ntag in steps otherwise we overflow the buffer + for (i in 0..tagSize step bufferSize - 1) { + val dataStart = i / pageSize + var dataEnd = (i + bufferSize) / pageSize + + if (dataEnd > lastPage) { + dataEnd = lastPage + } + + try { + val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1) + System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize) + } catch (e: IOException) { + return null; + } + } + return tagData + } + + private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x30.toByte(), + (page and 0xFF).toByte() + ) + ) + } + + private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x3A.toByte(), + (start and 0xFF).toByte(), + (end and 0xFF).toByte() + ) + ) + } + + private fun ntag215PWrite( + amiibo: NfcA, + page: Int, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0xA2.toByte(), + (page and 0xFF).toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } + + private fun ntag215PwdAuth( + amiibo: NfcA, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x1B.toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt new file mode 100755 index 000000000..87ee7f2e6 --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_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/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt new file mode 100755 index 000000000..e55767c0f --- /dev/null +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.content.res.Configuration +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.preference.PreferenceManager +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.ui.main.ThemeProvider +import kotlin.math.roundToInt + +object ThemeHelper { + const val SYSTEM_BAR_ALPHA = 0.9f + + private const val DEFAULT = 0 + private const val MATERIAL_YOU = 1 + + fun setTheme(activity: AppCompatActivity) { + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + setThemeMode(activity) + when (preferences.getInt(Settings.PREF_THEME, 0)) { + DEFAULT -> activity.setTheme(R.style.Theme_Yuzu_Main) + MATERIAL_YOU -> activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou) + } + + // Using a specific night mode check because this could apply incorrectly when using the + // light app mode, dark system mode, and black backgrounds. Launching the settings activity + // will then show light mode colors/navigation bars but with black backgrounds. + if (preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) + && isNightMode(activity) + ) { + activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark) + } + } + + @ColorInt + fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int { + return Color.argb( + (alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color), + Color.green(color), Color.blue(color) + ) + } + + fun setCorrectTheme(activity: AppCompatActivity) { + val currentTheme = (activity as ThemeProvider).themeId + setTheme(activity) + if (currentTheme != (activity as ThemeProvider).themeId) { + activity.recreate() + } + } + + fun setThemeMode(activity: AppCompatActivity) { + val themeMode = PreferenceManager.getDefaultSharedPreferences(activity.applicationContext) + .getInt(Settings.PREF_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + activity.delegate.localNightMode = themeMode + val windowController = WindowCompat.getInsetsController( + activity.window, + activity.window.decorView + ) + when (themeMode) { + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) { + false -> setLightModeSystemBars(windowController) + true -> setDarkModeSystemBars(windowController) + } + AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController) + AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController) + } + } + + private fun isNightMode(activity: AppCompatActivity): Boolean { + return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + } + + private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = true + windowController.isAppearanceLightNavigationBars = true + } + + private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) { + windowController.isAppearanceLightStatusBars = false + windowController.isAppearanceLightNavigationBars = false + } +} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt new file mode 100755 index 000000000..041781577 --- /dev/null +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2023 yuzu Emulator Project +# SPDX-License-Identifier: GPL-3.0-or-later + +add_library(yuzu-android SHARED + android_common/android_common.cpp + android_common/android_common.h + applets/software_keyboard.cpp + applets/software_keyboard.h + config.cpp + config.h + default_ini.h + emu_window/emu_window.cpp + emu_window/emu_window.h + id_cache.cpp + id_cache.h + native.cpp + native.h +) + +set_property(TARGET yuzu-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR}) + +target_link_libraries(yuzu-android PRIVATE audio_core common core input_common) +target_link_libraries(yuzu-android PRIVATE android camera2ndk EGL glad inih jnigraphics log) +if (ARCHITECTURE_arm64) + target_link_libraries(yuzu-android PRIVATE adrenotools) +endif() + +set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} yuzu-android) diff --git a/src/android/app/src/main/jni/android_common/android_common.cpp b/src/android/app/src/main/jni/android_common/android_common.cpp new file mode 100755 index 000000000..52d8ecfeb --- /dev/null +++ b/src/android/app/src/main/jni/android_common/android_common.cpp @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu 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" + +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)); +} diff --git a/src/android/app/src/main/jni/android_common/android_common.h b/src/android/app/src/main/jni/android_common/android_common.h new file mode 100755 index 000000000..ccb0c06f7 --- /dev/null +++ b/src/android/app/src/main/jni/android_common/android_common.h @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +std::string GetJString(JNIEnv* env, jstring jstr); +jstring ToJString(JNIEnv* env, std::string_view str); +jstring ToJString(JNIEnv* env, std::u16string_view str); diff --git a/src/android/app/src/main/jni/applets/software_keyboard.cpp b/src/android/app/src/main/jni/applets/software_keyboard.cpp new file mode 100755 index 000000000..74e040478 --- /dev/null +++ b/src/android/app/src/main/jni/applets/software_keyboard.cpp @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include + +#include + +#include "common/logging/log.h" +#include "common/string_util.h" +#include "core/core.h" +#include "jni/android_common/android_common.h" +#include "jni/applets/software_keyboard.h" +#include "jni/id_cache.h" + +static jclass s_software_keyboard_class; +static jclass s_keyboard_config_class; +static jclass s_keyboard_data_class; +static jmethodID s_swkbd_execute_normal; +static jmethodID s_swkbd_execute_inline; + +namespace SoftwareKeyboard { + +static jobject ToJKeyboardParams(const Core::Frontend::KeyboardInitializeParameters& config) { + JNIEnv* env = IDCache::GetEnvForThread(); + jobject object = env->AllocObject(s_keyboard_config_class); + + env->SetObjectField(object, + env->GetFieldID(s_keyboard_config_class, "ok_text", "Ljava/lang/String;"), + ToJString(env, config.ok_text)); + env->SetObjectField( + object, env->GetFieldID(s_keyboard_config_class, "header_text", "Ljava/lang/String;"), + ToJString(env, config.header_text)); + env->SetObjectField(object, + env->GetFieldID(s_keyboard_config_class, "sub_text", "Ljava/lang/String;"), + ToJString(env, config.sub_text)); + env->SetObjectField( + object, env->GetFieldID(s_keyboard_config_class, "guide_text", "Ljava/lang/String;"), + ToJString(env, config.guide_text)); + env->SetObjectField( + object, env->GetFieldID(s_keyboard_config_class, "initial_text", "Ljava/lang/String;"), + ToJString(env, config.initial_text)); + env->SetShortField(object, + env->GetFieldID(s_keyboard_config_class, "left_optional_symbol_key", "S"), + static_cast(config.left_optional_symbol_key)); + env->SetShortField(object, + env->GetFieldID(s_keyboard_config_class, "right_optional_symbol_key", "S"), + static_cast(config.right_optional_symbol_key)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "max_text_length", "I"), + static_cast(config.max_text_length)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "min_text_length", "I"), + static_cast(config.min_text_length)); + env->SetIntField(object, + env->GetFieldID(s_keyboard_config_class, "initial_cursor_position", "I"), + static_cast(config.initial_cursor_position)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "type", "I"), + static_cast(config.type)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "password_mode", "I"), + static_cast(config.password_mode)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "text_draw_type", "I"), + static_cast(config.text_draw_type)); + env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "key_disable_flags", "I"), + static_cast(config.key_disable_flags.raw)); + env->SetBooleanField(object, + env->GetFieldID(s_keyboard_config_class, "use_blur_background", "Z"), + static_cast(config.use_blur_background)); + env->SetBooleanField(object, + env->GetFieldID(s_keyboard_config_class, "enable_backspace_button", "Z"), + static_cast(config.enable_backspace_button)); + env->SetBooleanField(object, + env->GetFieldID(s_keyboard_config_class, "enable_return_button", "Z"), + static_cast(config.enable_return_button)); + env->SetBooleanField(object, + env->GetFieldID(s_keyboard_config_class, "disable_cancel_button", "Z"), + static_cast(config.disable_cancel_button)); + + return object; +} + +AndroidKeyboard::ResultData AndroidKeyboard::ResultData::CreateFromFrontend(jobject object) { + JNIEnv* env = IDCache::GetEnvForThread(); + const jstring string = reinterpret_cast(env->GetObjectField( + object, env->GetFieldID(s_keyboard_data_class, "text", "Ljava/lang/String;"))); + return ResultData{GetJString(env, string), + static_cast(env->GetIntField( + object, env->GetFieldID(s_keyboard_data_class, "result", "I")))}; +} + +AndroidKeyboard::~AndroidKeyboard() = default; + +void AndroidKeyboard::InitializeKeyboard( + bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters, + SubmitNormalCallback submit_normal_callback_, SubmitInlineCallback submit_inline_callback_) { + if (is_inline) { + LOG_WARNING( + Frontend, + "(STUBBED) called, backend requested to initialize the inline software keyboard."); + + submit_inline_callback = std::move(submit_inline_callback_); + } else { + LOG_WARNING( + Frontend, + "(STUBBED) called, backend requested to initialize the normal software keyboard."); + + submit_normal_callback = std::move(submit_normal_callback_); + } + + parameters = std::move(initialize_parameters); + + LOG_INFO(Frontend, + "\nKeyboardInitializeParameters:" + "\nok_text={}" + "\nheader_text={}" + "\nsub_text={}" + "\nguide_text={}" + "\ninitial_text={}" + "\nmax_text_length={}" + "\nmin_text_length={}" + "\ninitial_cursor_position={}" + "\ntype={}" + "\npassword_mode={}" + "\ntext_draw_type={}" + "\nkey_disable_flags={}" + "\nuse_blur_background={}" + "\nenable_backspace_button={}" + "\nenable_return_button={}" + "\ndisable_cancel_button={}", + Common::UTF16ToUTF8(parameters.ok_text), Common::UTF16ToUTF8(parameters.header_text), + Common::UTF16ToUTF8(parameters.sub_text), Common::UTF16ToUTF8(parameters.guide_text), + Common::UTF16ToUTF8(parameters.initial_text), parameters.max_text_length, + parameters.min_text_length, parameters.initial_cursor_position, parameters.type, + parameters.password_mode, parameters.text_draw_type, parameters.key_disable_flags.raw, + parameters.use_blur_background, parameters.enable_backspace_button, + parameters.enable_return_button, parameters.disable_cancel_button); +} + +void AndroidKeyboard::ShowNormalKeyboard() const { + LOG_DEBUG(Frontend, "called, backend requested to show the normal software keyboard."); + + ResultData data{}; + + // Pivot to a new thread, as we cannot call GetEnvForThread() from a Fiber. + std::thread([&] { + data = ResultData::CreateFromFrontend(IDCache::GetEnvForThread()->CallStaticObjectMethod( + s_software_keyboard_class, s_swkbd_execute_normal, ToJKeyboardParams(parameters))); + }).join(); + + SubmitNormalText(data); +} + +void AndroidKeyboard::ShowTextCheckDialog( + Service::AM::Applets::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) const { + LOG_WARNING(Frontend, "(STUBBED) called, backend requested to show the text check dialog."); +} + +void AndroidKeyboard::ShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters) const { + LOG_WARNING(Frontend, + "(STUBBED) called, backend requested to show the inline software keyboard."); + + LOG_INFO(Frontend, + "\nInlineAppearParameters:" + "\nmax_text_length={}" + "\nmin_text_length={}" + "\nkey_top_scale_x={}" + "\nkey_top_scale_y={}" + "\nkey_top_translate_x={}" + "\nkey_top_translate_y={}" + "\ntype={}" + "\nkey_disable_flags={}" + "\nkey_top_as_floating={}" + "\nenable_backspace_button={}" + "\nenable_return_button={}" + "\ndisable_cancel_button={}", + appear_parameters.max_text_length, appear_parameters.min_text_length, + appear_parameters.key_top_scale_x, appear_parameters.key_top_scale_y, + appear_parameters.key_top_translate_x, appear_parameters.key_top_translate_y, + appear_parameters.type, appear_parameters.key_disable_flags.raw, + appear_parameters.key_top_as_floating, appear_parameters.enable_backspace_button, + appear_parameters.enable_return_button, appear_parameters.disable_cancel_button); + + // Pivot to a new thread, as we cannot call GetEnvForThread() from a Fiber. + m_is_inline_active = true; + std::thread([&] { + IDCache::GetEnvForThread()->CallStaticVoidMethod( + s_software_keyboard_class, s_swkbd_execute_inline, ToJKeyboardParams(parameters)); + }).join(); +} + +void AndroidKeyboard::HideInlineKeyboard() const { + LOG_WARNING(Frontend, + "(STUBBED) called, backend requested to hide the inline software keyboard."); +} + +void AndroidKeyboard::InlineTextChanged( + Core::Frontend::InlineTextParameters text_parameters) const { + LOG_WARNING(Frontend, + "(STUBBED) called, backend requested to change the inline keyboard text."); + + LOG_INFO(Frontend, + "\nInlineTextParameters:" + "\ninput_text={}" + "\ncursor_position={}", + Common::UTF16ToUTF8(text_parameters.input_text), text_parameters.cursor_position); + + submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString, + text_parameters.input_text, text_parameters.cursor_position); +} + +void AndroidKeyboard::ExitKeyboard() const { + LOG_WARNING(Frontend, "(STUBBED) called, backend requested to exit the software keyboard."); +} + +void AndroidKeyboard::SubmitInlineKeyboardText(std::u16string submitted_text) { + if (!m_is_inline_active) { + return; + } + + m_current_text += submitted_text; + + submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString, m_current_text, + m_current_text.size()); +} + +void AndroidKeyboard::SubmitInlineKeyboardInput(int key_code) { + static constexpr int KEYCODE_BACK = 4; + static constexpr int KEYCODE_ENTER = 66; + static constexpr int KEYCODE_DEL = 67; + + if (!m_is_inline_active) { + return; + } + + switch (key_code) { + case KEYCODE_BACK: + case KEYCODE_ENTER: + m_is_inline_active = false; + submit_inline_callback(Service::AM::Applets::SwkbdReplyType::DecidedEnter, m_current_text, + static_cast(m_current_text.size())); + break; + case KEYCODE_DEL: + m_current_text.pop_back(); + submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString, m_current_text, + m_current_text.size()); + break; + } +} + +void AndroidKeyboard::SubmitNormalText(const ResultData& data) const { + submit_normal_callback(data.result, Common::UTF8ToUTF16(data.text), true); +} + +void InitJNI(JNIEnv* env) { + s_software_keyboard_class = reinterpret_cast( + env->NewGlobalRef(env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard"))); + s_keyboard_config_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig"))); + s_keyboard_data_class = reinterpret_cast(env->NewGlobalRef( + env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardData"))); + + s_swkbd_execute_normal = env->GetStaticMethodID( + s_software_keyboard_class, "executeNormal", + "(Lorg/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig;)Lorg/yuzu/yuzu_emu/" + "applets/keyboard/SoftwareKeyboard$KeyboardData;"); + s_swkbd_execute_inline = env->GetStaticMethodID( + s_software_keyboard_class, "executeInline", + "(Lorg/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig;)V"); +} + +void CleanupJNI(JNIEnv* env) { + env->DeleteGlobalRef(s_software_keyboard_class); + env->DeleteGlobalRef(s_keyboard_config_class); + env->DeleteGlobalRef(s_keyboard_data_class); +} + +} // namespace SoftwareKeyboard diff --git a/src/android/app/src/main/jni/applets/software_keyboard.h b/src/android/app/src/main/jni/applets/software_keyboard.h new file mode 100755 index 000000000..b2fb59b68 --- /dev/null +++ b/src/android/app/src/main/jni/applets/software_keyboard.h @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include "core/frontend/applets/software_keyboard.h" + +namespace SoftwareKeyboard { + +class AndroidKeyboard final : public Core::Frontend::SoftwareKeyboardApplet { +public: + ~AndroidKeyboard() override; + + void Close() const override { + ExitKeyboard(); + } + + void InitializeKeyboard(bool is_inline, + Core::Frontend::KeyboardInitializeParameters initialize_parameters, + SubmitNormalCallback submit_normal_callback_, + SubmitInlineCallback submit_inline_callback_) override; + + void ShowNormalKeyboard() const override; + + void ShowTextCheckDialog(Service::AM::Applets::SwkbdTextCheckResult text_check_result, + std::u16string text_check_message) const override; + + void ShowInlineKeyboard( + Core::Frontend::InlineAppearParameters appear_parameters) const override; + + void HideInlineKeyboard() const override; + + void InlineTextChanged(Core::Frontend::InlineTextParameters text_parameters) const override; + + void ExitKeyboard() const override; + + void SubmitInlineKeyboardText(std::u16string submitted_text); + + void SubmitInlineKeyboardInput(int key_code); + +private: + struct ResultData { + static ResultData CreateFromFrontend(jobject object); + + std::string text; + Service::AM::Applets::SwkbdResult result{}; + }; + + void SubmitNormalText(const ResultData& result) const; + + Core::Frontend::KeyboardInitializeParameters parameters{}; + + mutable SubmitNormalCallback submit_normal_callback; + mutable SubmitInlineCallback submit_inline_callback; + +private: + mutable bool m_is_inline_active{}; + std::u16string m_current_text; +}; + +// Should be called in JNI_Load +void InitJNI(JNIEnv* env); + +// Should be called in JNI_Unload +void CleanupJNI(JNIEnv* env); + +} // namespace SoftwareKeyboard + +// Native function calls +extern "C" { +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters( + JNIEnv* env, jclass clazz, jstring text); + +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput( + JNIEnv* env, jclass clazz, jstring text); +} diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp new file mode 100755 index 000000000..2d622a048 --- /dev/null +++ b/src/android/app/src/main/jni/config.cpp @@ -0,0 +1,297 @@ +// SPDX-FileCopyrightText: 2023 yuzu 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 "core/hle/service/acc/profile_manager.h" +#include "input_common/main.h" +#include "jni/config.h" +#include "jni/default_ini.h" + +namespace FS = Common::FS; + +Config::Config(std::optional config_path) + : config_loc{config_path.value_or(FS::GetYuzuPath(FS::YuzuPath::ConfigDir) / "config.ini")}, + config{std::make_unique(FS::PathToUTF8String(config_loc))} { + Reload(); +} + +Config::~Config() = default; + +bool Config::LoadINI(const std::string& default_contents, bool retry) { + 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::SetYuzuPath(FS::YuzuPath::NANDDir, + config->Get("Data Storage", "nand_directory", + FS::GetYuzuPathString(FS::YuzuPath::NANDDir))); + FS::SetYuzuPath(FS::YuzuPath::SDMCDir, + config->Get("Data Storage", "sdmc_directory", + FS::GetYuzuPathString(FS::YuzuPath::SDMCDir))); + FS::SetYuzuPath(FS::YuzuPath::LoadDir, + config->Get("Data Storage", "load_directory", + FS::GetYuzuPathString(FS::YuzuPath::LoadDir))); + FS::SetYuzuPath(FS::YuzuPath::DumpDir, + config->Get("Data Storage", "dump_directory", + FS::GetYuzuPathString(FS::YuzuPath::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 = config->GetBoolean("System", "use_docked_mode", false); + + 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(std::nullopt); + } + + 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 = std::nullopt; + } + + 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.use_unsafe_extended_memory_layout); + + // 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 = config->GetInteger("Renderer", "max_anisotropy", 1); + + // Disable ASTC compute by default on Android + Settings::values.accelerate_astc = config->GetBoolean("Renderer", "accelerate_astc", false); + + // Enable asynchronous presentation by default on Android + Settings::values.async_presentation = + config->GetBoolean("Renderer", "async_presentation", true); + + // Enable force_max_clock by default on Android + Settings::values.renderer_force_max_clock = + config->GetBoolean("Renderer", "force_max_clock", true); + + // 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::stoul(line, 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.yuzu_username); + ReadSetting("WebService", Settings::values.yuzu_token); + + // Network + ReadSetting("Network", Settings::values.network_interface); +} + +void Config::Reload() { + LoadINI(DefaultINI::android_config_file); + ReadValues(); +} diff --git a/src/android/app/src/main/jni/config.h b/src/android/app/src/main/jni/config.h new file mode 100755 index 000000000..0d7d6e94d --- /dev/null +++ b/src/android/app/src/main/jni/config.h @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include + +#include "common/settings.h" + +class INIReader; + +class Config { + std::filesystem::path config_loc; + std::unique_ptr config; + + bool LoadINI(const std::string& default_contents = "", bool retry = true); + void ReadValues(); + +public: + explicit Config(std::optional config_path = std::nullopt); + ~Config(); + + void Reload(); + +private: + /** + * Applies a value read from the sdl2_config to a Setting. + * + * @param group The name of the INI group + * @param setting The yuzu setting to modify + */ + template + void ReadSetting(const std::string& group, Settings::Setting& setting); +}; diff --git a/src/android/app/src/main/jni/default_ini.h b/src/android/app/src/main/jni/default_ini.h new file mode 100755 index 000000000..c5dfaff54 --- /dev/null +++ b/src/android/app/src/main/jni/default_ini.h @@ -0,0 +1,507 @@ +// SPDX-FileCopyrightText: 2023 yuzu 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 = + +# Enable graphics API debugging mode. +# 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 = + +# 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 yuzu 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 yuzu 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 yuzu will dump the ExeFS of all games it attempts to load while loading them +dump_exefs=false +# Determines whether or not yuzu will dump all NSOs it attempts to load while loading them +dump_nso=false +# Determines whether or not yuzu will save the filesystem access log. +enable_fs_access_log=false +# Enables verbose reporting services +reporting_services = +# Determines whether or not yuzu 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.yuzu-emu.org +# Username and token for yuzu Web Service +# See https://profile.yuzu-emu.org/ for more info +yuzu_username = +yuzu_token = + +[Network] +# Name of the network interface device to use with yuzu 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/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp new file mode 100755 index 000000000..a890c6604 --- /dev/null +++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "common/logging/log.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" + +void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) { + 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); + m_input_subsystem->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); + m_input_subsystem->GetTouchScreen()->TouchMoved(touch_x, touch_y, id); +} + +void EmuWindow_Android::OnTouchReleased(int id) { + m_input_subsystem->GetTouchScreen()->TouchReleased(id); +} + +void EmuWindow_Android::OnGamepadButtonEvent(int player_index, int button_id, bool pressed) { + m_input_subsystem->GetVirtualGamepad()->SetButtonState(player_index, button_id, pressed); +} + +void EmuWindow_Android::OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y) { + m_input_subsystem->GetVirtualGamepad()->SetStickPosition(player_index, stick_id, x, y); +} + +void EmuWindow_Android::OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x, + float gyro_y, float gyro_z, float accel_x, + float accel_y, float accel_z) { + m_input_subsystem->GetVirtualGamepad()->SetMotionState( + player_index, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z); +} + +void EmuWindow_Android::OnReadNfcTag(std::span data) { + m_input_subsystem->GetVirtualAmiibo()->LoadAmiibo(data); +} + +void EmuWindow_Android::OnRemoveNfcTag() { + m_input_subsystem->GetVirtualAmiibo()->CloseAmiibo(); +} + +EmuWindow_Android::EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem, + ANativeWindow* surface, + std::shared_ptr driver_library) + : m_input_subsystem{input_subsystem}, m_driver_library{driver_library} { + LOG_INFO(Frontend, "initializing"); + + if (!surface) { + LOG_CRITICAL(Frontend, "surface is nullptr"); + return; + } + + 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.type = Core::Frontend::WindowSystemType::Android; + window_info.render_surface = reinterpret_cast(surface); + + m_input_subsystem->Initialize(); +} + +EmuWindow_Android::~EmuWindow_Android() { + m_input_subsystem->Shutdown(); +} diff --git a/src/android/app/src/main/jni/emu_window/emu_window.h b/src/android/app/src/main/jni/emu_window/emu_window.h new file mode 100755 index 000000000..b38087f73 --- /dev/null +++ b/src/android/app/src/main/jni/emu_window/emu_window.h @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include "core/frontend/emu_window.h" +#include "core/frontend/graphics_context.h" +#include "input_common/main.h" + +struct ANativeWindow; + +class GraphicsContext_Android final : public Core::Frontend::GraphicsContext { +public: + explicit GraphicsContext_Android(std::shared_ptr driver_library) + : m_driver_library{driver_library} {} + + ~GraphicsContext_Android() = default; + + std::shared_ptr GetDriverLibrary() override { + return m_driver_library; + } + +private: + std::shared_ptr m_driver_library; +}; + +class EmuWindow_Android final : public Core::Frontend::EmuWindow { + +public: + EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem, ANativeWindow* surface, + std::shared_ptr driver_library); + + ~EmuWindow_Android(); + + void OnSurfaceChanged(ANativeWindow* surface); + void OnTouchPressed(int id, float x, float y); + void OnTouchMoved(int id, float x, float y); + void OnTouchReleased(int id); + void OnGamepadButtonEvent(int player_index, int button_id, bool pressed); + void OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y); + void OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x, float gyro_y, + float gyro_z, float accel_x, float accel_y, float accel_z); + void OnReadNfcTag(std::span data); + void OnRemoveNfcTag(); + void OnFrameDisplayed() override {} + + std::unique_ptr CreateSharedContext() const override { + return {std::make_unique(m_driver_library)}; + } + bool IsShown() const override { + return true; + }; + +private: + InputCommon::InputSubsystem* m_input_subsystem{}; + + float m_window_width{}; + float m_window_height{}; + + std::shared_ptr m_driver_library; +}; diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp new file mode 100755 index 000000000..9cbbf23a3 --- /dev/null +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu 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 jmethodID s_exit_emulation_activity; +static jmethodID s_disk_cache_load_progress; + +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; +} + +jmethodID GetExitEmulationActivity() { + return s_exit_emulation_activity; +} + +jmethodID GetDiskCacheLoadProgress() { + return s_disk_cache_load_progress; +} + +} // 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/yuzu/yuzu_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/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress"))); + s_load_callback_stage_class = reinterpret_cast(env->NewGlobalRef(env->FindClass( + "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage"))); + + // 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"); + + // 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); + + // UnInitialize applets + SoftwareKeyboard::CleanupJNI(env); +} + +#ifdef __cplusplus +} +#endif diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h new file mode 100755 index 000000000..be535fe1e --- /dev/null +++ b/src/android/app/src/main/jni/id_cache.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include "video_core/rasterizer_interface.h" + +namespace IDCache { + +JNIEnv* GetEnvForThread(); +jclass GetNativeLibraryClass(); +jclass GetDiskCacheProgressClass(); +jclass GetDiskCacheLoadCallbackStageClass(); +jmethodID GetExitEmulationActivity(); +jmethodID GetDiskCacheLoadProgress(); + +} // namespace IDCache diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp new file mode 100755 index 000000000..bbe6abdb0 --- /dev/null +++ b/src/android/app/src/main/jni/native.cpp @@ -0,0 +1,758 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#ifdef ARCHITECTURE_arm64 +#include +#endif + +#include +#include + +#include "common/detached_tasks.h" +#include "common/dynamic_library.h" +#include "common/fs/path_util.h" +#include "common/logging/backend.h" +#include "common/logging/log.h" +#include "common/microprofile.h" +#include "common/scm_rev.h" +#include "common/scope_exit.h" +#include "common/settings.h" +#include "common/string_util.h" +#include "core/core.h" +#include "core/cpu_manager.h" +#include "core/crypto/key_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/vfs_real.h" +#include "core/frontend/applets/cabinet.h" +#include "core/frontend/applets/controller.h" +#include "core/frontend/applets/error.h" +#include "core/frontend/applets/general_frontend.h" +#include "core/frontend/applets/mii_edit.h" +#include "core/frontend/applets/profile_select.h" +#include "core/frontend/applets/software_keyboard.h" +#include "core/frontend/applets/web_browser.h" +#include "core/hid/emulated_controller.h" +#include "core/hid/hid_core.h" +#include "core/hid/hid_types.h" +#include "core/hle/service/acc/profile_manager.h" +#include "core/hle/service/am/applet_ae.h" +#include "core/hle/service/am/applet_oe.h" +#include "core/hle/service/am/applets/applets.h" +#include "core/hle/service/filesystem/filesystem.h" +#include "core/loader/loader.h" +#include "core/perf_stats.h" +#include "jni/android_common/android_common.h" +#include "jni/applets/software_keyboard.h" +#include "jni/config.h" +#include "jni/emu_window/emu_window.h" +#include "jni/id_cache.h" +#include "video_core/rasterizer_interface.h" +#include "video_core/renderer_base.h" + +namespace { + +class EmulationSession final { +public: + EmulationSession() { + m_vfs = std::make_shared(); + } + + ~EmulationSession() = default; + + static EmulationSession& GetInstance() { + return s_instance; + } + + const Core::System& System() const { + return m_system; + } + + Core::System& System() { + return m_system; + } + + const EmuWindow_Android& Window() const { + return *m_window; + } + + EmuWindow_Android& Window() { + return *m_window; + } + + ANativeWindow* NativeWindow() const { + return m_native_window; + } + + void SetNativeWindow(ANativeWindow* m_native_window_) { + m_native_window = m_native_window_; + } + + void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& custom_driver_dir, + const std::string& custom_driver_name, + const std::string& file_redirect_dir) { +#ifdef ARCHITECTURE_arm64 + void* handle{}; + const char* file_redirect_dir_{}; + int featureFlags{}; + + // Enable driver file redirection when renderer debugging is enabled. + if (Settings::values.renderer_debug && file_redirect_dir.size()) { + featureFlags |= ADRENOTOOLS_DRIVER_FILE_REDIRECT; + file_redirect_dir_ = file_redirect_dir.c_str(); + } + + // Try to load a custom driver. + if (custom_driver_name.size()) { + handle = adrenotools_open_libvulkan( + RTLD_NOW, featureFlags | ADRENOTOOLS_DRIVER_CUSTOM, nullptr, hook_lib_dir.c_str(), + custom_driver_dir.c_str(), custom_driver_name.c_str(), file_redirect_dir_, nullptr); + } + + // Try to load the system driver. + if (!handle) { + handle = + adrenotools_open_libvulkan(RTLD_NOW, featureFlags, nullptr, hook_lib_dir.c_str(), + nullptr, nullptr, file_redirect_dir_, nullptr); + } + + m_vulkan_library = std::make_shared(handle); +#endif + } + + bool IsRunning() const { + std::scoped_lock lock(m_mutex); + return m_is_running; + } + + const Core::PerfStatsResults& PerfStats() const { + std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex); + return m_perf_stats; + } + + void SurfaceChanged() { + if (!IsRunning()) { + return; + } + m_window->OnSurfaceChanged(m_native_window); + } + + Core::SystemResultStatus InitializeEmulation(const std::string& filepath) { + std::scoped_lock lock(m_mutex); + + // Loads the configuration. + Config{}; + + // Create the render window. + m_window = std::make_unique(&m_input_subsystem, m_native_window, + m_vulkan_library); + + // Initialize system. + auto android_keyboard = std::make_unique(); + m_software_keyboard = android_keyboard.get(); + m_system.SetShuttingDown(false); + m_system.ApplySettings(); + m_system.HIDCore().ReloadInputDevices(); + m_system.SetContentProvider(std::make_unique()); + m_system.SetFilesystem(std::make_shared()); + m_system.SetAppletFrontendSet({ + nullptr, // Amiibo Settings + nullptr, // Controller Selector + nullptr, // Error Display + nullptr, // Mii Editor + nullptr, // Parental Controls + nullptr, // Photo Viewer + nullptr, // Profile Selector + std::move(android_keyboard), // Software Keyboard + nullptr, // Web Browser + }); + m_system.GetFileSystemController().CreateFactories(*m_system.GetFilesystem()); + + // Initialize account manager + m_profile_manager = std::make_unique(); + + // Load the ROM. + m_load_result = m_system.Load(EmulationSession::GetInstance().Window(), filepath); + if (m_load_result != Core::SystemResultStatus::Success) { + return m_load_result; + } + + // Complete initialization. + m_system.GPU().Start(); + m_system.GetCpuManager().OnGpuReady(); + m_system.RegisterExitCallback([&] { HaltEmulation(); }); + + return Core::SystemResultStatus::Success; + } + + void ShutdownEmulation() { + std::scoped_lock lock(m_mutex); + + m_is_running = false; + + // Unload user input. + m_system.HIDCore().UnloadInputDevices(); + + // Shutdown the main emulated process + if (m_load_result == Core::SystemResultStatus::Success) { + m_system.DetachDebugger(); + m_system.ShutdownMainProcess(); + m_detached_tasks.WaitForAllTasks(); + m_load_result = Core::SystemResultStatus::ErrorNotInitialized; + } + + // Tear down the render window. + m_window.reset(); + } + + void PauseEmulation() { + std::scoped_lock lock(m_mutex); + m_system.Pause(); + } + + void UnPauseEmulation() { + std::scoped_lock lock(m_mutex); + m_system.Run(); + } + + void HaltEmulation() { + std::scoped_lock lock(m_mutex); + m_is_running = false; + m_cv.notify_one(); + } + + void RunEmulation() { + { + std::scoped_lock lock(m_mutex); + m_is_running = true; + } + + // Load the disk shader cache. + if (Settings::values.use_disk_shader_cache.GetValue()) { + LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0); + m_system.Renderer().ReadRasterizer()->LoadDiskResources( + m_system.GetApplicationProcessProgramID(), std::stop_token{}, + LoadDiskCacheProgress); + LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Complete, 0, 0); + } + + void(m_system.Run()); + + if (m_system.DebuggerEnabled()) { + m_system.InitializeDebugger(); + } + + while (true) { + { + std::unique_lock lock(m_mutex); + if (m_cv.wait_for(lock, std::chrono::milliseconds(800), + [&]() { return !m_is_running; })) { + // Emulation halted. + break; + } + } + { + // Refresh performance stats. + std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex); + m_perf_stats = m_system.GetAndResetPerfStats(); + } + } + } + + std::string GetRomTitle(const std::string& path) { + return GetRomMetadata(path).title; + } + + std::vector GetRomIcon(const std::string& path) { + return GetRomMetadata(path).icon; + } + + void ResetRomMetadata() { + m_rom_metadata_cache.clear(); + } + + bool IsHandheldOnly() { + const auto npad_style_set = m_system.HIDCore().GetSupportedStyleTag(); + + if (npad_style_set.fullkey == 1) { + return false; + } + + if (npad_style_set.handheld == 0) { + return false; + } + + return !Settings::values.use_docked_mode.GetValue(); + } + + void SetDeviceType(int index, int type) { + auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); + controller->SetNpadStyleIndex(static_cast(type)); + } + + void OnGamepadConnectEvent(int index) { + auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); + + // Ensure that player1 is configured correctly and handheld disconnected + if (controller->GetNpadIdType() == Core::HID::NpadIdType::Player1) { + auto handheld = + m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld); + + if (controller->GetNpadStyleIndex() == Core::HID::NpadStyleIndex::Handheld) { + handheld->SetNpadStyleIndex(Core::HID::NpadStyleIndex::ProController); + controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::ProController); + handheld->Disconnect(); + } + } + + // Ensure that handheld is configured correctly and player 1 disconnected + if (controller->GetNpadIdType() == Core::HID::NpadIdType::Handheld) { + auto player1 = m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1); + + if (controller->GetNpadStyleIndex() != Core::HID::NpadStyleIndex::Handheld) { + player1->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld); + controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld); + player1->Disconnect(); + } + } + + if (!controller->IsConnected()) { + controller->Connect(); + } + } + + void OnGamepadDisconnectEvent(int index) { + auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index); + controller->Disconnect(); + } + + SoftwareKeyboard::AndroidKeyboard* SoftwareKeyboard() { + return m_software_keyboard; + } + +private: + struct RomMetadata { + std::string title; + std::vector icon; + }; + + RomMetadata GetRomMetadata(const std::string& path) { + if (auto search = m_rom_metadata_cache.find(path); search != m_rom_metadata_cache.end()) { + return search->second; + } + + return CacheRomMetadata(path); + } + + RomMetadata CacheRomMetadata(const std::string& path) { + const auto file = Core::GetGameFileFromPath(m_vfs, path); + const auto loader = Loader::GetLoader(EmulationSession::GetInstance().System(), file, 0, 0); + + RomMetadata entry; + loader->ReadTitle(entry.title); + loader->ReadIcon(entry.icon); + + m_rom_metadata_cache[path] = entry; + + return entry; + } + +private: + static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max) { + JNIEnv* env = IDCache::GetEnvForThread(); + env->CallStaticVoidMethod(IDCache::GetDiskCacheProgressClass(), + IDCache::GetDiskCacheLoadProgress(), static_cast(stage), + static_cast(progress), static_cast(max)); + } + +private: + static EmulationSession s_instance; + + // Frontend management + std::unordered_map m_rom_metadata_cache; + + // Window management + std::unique_ptr m_window; + ANativeWindow* m_native_window{}; + + // Core emulation + Core::System m_system; + InputCommon::InputSubsystem m_input_subsystem; + Common::DetachedTasks m_detached_tasks; + Core::PerfStatsResults m_perf_stats{}; + std::shared_ptr m_vfs; + Core::SystemResultStatus m_load_result{Core::SystemResultStatus::ErrorNotInitialized}; + bool m_is_running{}; + SoftwareKeyboard::AndroidKeyboard* m_software_keyboard{}; + std::unique_ptr m_profile_manager; + + // GPU driver parameters + std::shared_ptr m_vulkan_library; + + // Synchronization + std::condition_variable_any m_cv; + mutable std::mutex m_perf_stats_mutex; + mutable std::mutex m_mutex; +}; + +/*static*/ EmulationSession EmulationSession::s_instance; + +} // Anonymous namespace + +static Core::SystemResultStatus RunEmulation(const std::string& filepath) { + Common::Log::Initialize(); + Common::Log::SetColorConsoleBackendEnabled(true); + Common::Log::Start(); + + MicroProfileOnThreadCreate("EmuThread"); + SCOPE_EXIT({ MicroProfileShutdown(); }); + + LOG_INFO(Frontend, "starting"); + + if (filepath.empty()) { + LOG_CRITICAL(Frontend, "failed to load: filepath empty!"); + return Core::SystemResultStatus::ErrorLoader; + } + + SCOPE_EXIT({ EmulationSession::GetInstance().ShutdownEmulation(); }); + + const auto result = EmulationSession::GetInstance().InitializeEmulation(filepath); + if (result != Core::SystemResultStatus::Success) { + return result; + } + + EmulationSession::GetInstance().RunEmulation(); + + return Core::SystemResultStatus::Success; +} + +extern "C" { + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceChanged(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jobject surf) { + EmulationSession::GetInstance().SetNativeWindow(ANativeWindow_fromSurface(env, surf)); + EmulationSession::GetInstance().SurfaceChanged(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + ANativeWindow_release(EmulationSession::GetInstance().NativeWindow()); + EmulationSession::GetInstance().SetNativeWindow(nullptr); + EmulationSession::GetInstance().SurfaceChanged(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_notifyOrientationChange(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jint layout_option, + jint rotation) {} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_directory) { + Common::FS::SetAppDirectory(GetJString(env, j_directory)); +} + +void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver( + JNIEnv* env, [[maybe_unused]] jclass clazz, jstring hook_lib_dir, jstring custom_driver_dir, + jstring custom_driver_name, jstring file_redirect_dir) { + EmulationSession::GetInstance().InitializeGpuDriver( + GetJString(env, hook_lib_dir), GetJString(env, custom_driver_dir), + GetJString(env, custom_driver_name), GetJString(env, file_redirect_dir)); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_reloadKeys(JNIEnv* env, + [[maybe_unused]] jclass clazz) { + Core::Crypto::KeyManager::Instance().ReloadKeys(); + return static_cast(Core::Crypto::KeyManager::Instance().AreKeysLoaded()); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_unPauseEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + EmulationSession::GetInstance().UnPauseEmulation(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_pauseEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + EmulationSession::GetInstance().PauseEmulation(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_stopEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + EmulationSession::GetInstance().HaltEmulation(); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_resetRomMetadata([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + EmulationSession::GetInstance().ResetRomMetadata(); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isRunning([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return static_cast(EmulationSession::GetInstance().IsRunning()); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isHandheldOnly([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return EmulationSession::GetInstance().IsHandheldOnly(); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_setDeviceType([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jint j_device, jint j_type) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().SetDeviceType(j_device, j_type); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadConnectEvent([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jint j_device) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().OnGamepadConnectEvent(j_device); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadDisconnectEvent( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint j_device) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().OnGamepadDisconnectEvent(j_device); + } + return static_cast(true); +} +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadButtonEvent([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jint j_device, + jint j_button, jint action) { + if (EmulationSession::GetInstance().IsRunning()) { + // Ensure gamepad is connected + EmulationSession::GetInstance().OnGamepadConnectEvent(j_device); + EmulationSession::GetInstance().Window().OnGamepadButtonEvent(j_device, j_button, + action != 0); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadJoystickEvent([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jint j_device, jint stick_id, + jfloat x, jfloat y) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnGamepadJoystickEvent(j_device, stick_id, x, y); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadMotionEvent( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint j_device, + jlong delta_timestamp, jfloat gyro_x, jfloat gyro_y, jfloat gyro_z, jfloat accel_x, + jfloat accel_y, jfloat accel_z) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnGamepadMotionEvent( + j_device, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onReadNfcTag([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + 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().Window().OnReadNfcTag(data); + } + return static_cast(true); +} + +jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onRemoveNfcTag([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnRemoveNfcTag(); + } + return static_cast(true); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchPressed([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, jint id, + jfloat x, jfloat y) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchPressed(id, x, y); + } +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, jint id, + jfloat x, jfloat y) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchMoved(id, x, y); + } +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchReleased([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, jint id) { + if (EmulationSession::GetInstance().IsRunning()) { + EmulationSession::GetInstance().Window().OnTouchReleased(id); + } +} + +jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getIcon([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jstring j_filename) { + auto icon_data = EmulationSession::GetInstance().GetRomIcon(GetJString(env, j_filename)); + jbyteArray icon = env->NewByteArray(static_cast(icon_data.size())); + env->SetByteArrayRegion(icon, 0, env->GetArrayLength(icon), + reinterpret_cast(icon_data.data())); + return icon; +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getTitle([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jstring j_filename) { + auto title = EmulationSession::GetInstance().GetRomTitle(GetJString(env, j_filename)); + return env->NewStringUTF(title.c_str()); +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getDescription([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + return j_filename; +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getGameId([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_filename) { + return j_filename; +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getRegions([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jstring j_filename) { + return env->NewStringUTF(""); +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCompany([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + [[maybe_unused]] jstring j_filename) { + return env->NewStringUTF(""); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmulation + [[maybe_unused]] (JNIEnv* env, [[maybe_unused]] jclass clazz) { + // Create the default config.ini. + Config{}; + // Initialize the emulated system. + EmulationSession::GetInstance().System().Initialize(); +} + +jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + return {}; +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, [[maybe_unused]] jstring j_file, + [[maybe_unused]] jstring j_savestate, [[maybe_unused]] jboolean j_delete_savestate) {} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + Config{}; +} + +jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getUserSetting([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_game_id, jstring j_section, + jstring j_key) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + std::string_view section = env->GetStringUTFChars(j_section, 0); + std::string_view key = env->GetStringUTFChars(j_key, 0); + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); + env->ReleaseStringUTFChars(j_section, section.data()); + env->ReleaseStringUTFChars(j_key, key.data()); + + return env->NewStringUTF(""); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_setUserSetting([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_game_id, jstring j_section, + jstring j_key, jstring j_value) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + std::string_view section = env->GetStringUTFChars(j_section, 0); + std::string_view key = env->GetStringUTFChars(j_key, 0); + std::string_view value = env->GetStringUTFChars(j_value, 0); + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); + env->ReleaseStringUTFChars(j_section, section.data()); + env->ReleaseStringUTFChars(j_key, key.data()); + env->ReleaseStringUTFChars(j_value, value.data()); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_initGameIni([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_game_id) { + std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); + + env->ReleaseStringUTFChars(j_game_id, game_id.data()); +} + +jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + jdoubleArray j_stats = env->NewDoubleArray(4); + + if (EmulationSession::GetInstance().IsRunning()) { + const auto results = EmulationSession::GetInstance().PerfStats(); + + // Converting the structure into an array makes it easier to pass it to the frontend + double stats[4] = {results.system_fps, results.average_game_fps, results.frametime, + results.emulation_speed}; + + env->SetDoubleArrayRegion(j_stats, 0, 4, stats); + } + + return j_stats; +} + +void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_path) {} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz, + jstring j_path) { + const std::string path = GetJString(env, j_path); + + const Core::SystemResultStatus result{RunEmulation(path)}; + if (result != Core::SystemResultStatus::Success) { + env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(), + IDCache::GetExitEmulationActivity(), static_cast(result)); + } +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_logDeviceInfo([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jclass clazz) { + LOG_INFO(Frontend, "yuzu Version: {}-{}", Common::g_scm_branch, Common::g_scm_desc); + LOG_INFO(Frontend, "Host OS: Android API level {}", android_get_device_api_level()); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardText(JNIEnv* env, jclass clazz, + jstring j_text) { + const std::u16string input = Common::UTF8ToUTF16(GetJString(env, j_text)); + EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardText(input); +} + +void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env, jclass clazz, + jint j_key_code) { + EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code); +} + +} // extern "C" diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h new file mode 100755 index 000000000..24dcbbcb8 --- /dev/null +++ b/src/android/app/src/main/jni/native.h @@ -0,0 +1,165 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +// Function calls from the Java side +#ifdef __cplusplus +extern "C" { +#endif + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ResetRomMetadata(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_isHandheldOnly(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_setDeviceType(JNIEnv* env, + jclass clazz, + jstring j_device, + jstring j_type); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadConnectEvent( + JNIEnv* env, jclass clazz, jstring j_device); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadDisconnectEvent( + JNIEnv* env, jclass clazz, jstring j_device); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadMoveEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint axis, jfloat x, jfloat y); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadAxisEvent( + JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onReadNfcTag(JNIEnv* env, + jclass clazz, + jbyteArray j_data); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onRemoveNfcTag(JNIEnv* env, + jclass clazz); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, + jclass clazz, + jfloat x, jfloat y, + jboolean pressed); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz, + jfloat x, jfloat y); + +JNIEXPORT jbyteArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env, + jclass clazz, + jstring j_file); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGameId(JNIEnv* env, jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetRegions(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetCompany(JNIEnv* env, + jclass clazz, + jstring j_filename); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetAppDirectory(JNIEnv* env, + jclass clazz, + jstring j_directory); + +JNIEXPORT void JNICALL +Java_org_yuzu_yuzu_1emu_NativeLibrary_Java_org_yuzu_yuzu_1emu_NativeLibrary_InitializeGpuDriver( + JNIEnv* env, jclass clazz, jstring hook_lib_dir, jstring custom_driver_dir, + jstring custom_driver_name, jstring file_redirect_dir); + +JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadKeys(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_SetSysDirectory( + JNIEnv* env, jclass clazz, jstring path_); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env, + jclass clazz, + jstring path); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitializeEmulation(JNIEnv* env, + jclass clazz); + +JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, + jclass clazz); +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetProfiling(JNIEnv* env, jclass clazz, + jboolean enable); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_NotifyOrientationChange( + JNIEnv* env, jclass clazz, jint layout_option, jint rotation); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_Run__Ljava_lang_String_2( + JNIEnv* env, jclass clazz, jstring j_path); + +JNIEXPORT void JNICALL +Java_org_yuzu_yuzu_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z( + JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, + jclass clazz, + jobject surf); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitGameIni(JNIEnv* env, jclass clazz, + jstring j_game_id); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetUserSetting( + JNIEnv* env, jclass clazz, jstring j_game_id, jstring j_section, jstring j_key, + jstring j_value); + +JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetUserSetting( + JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key); + +JNIEXPORT jdoubleArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, + jclass clazz); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SubmitInlineKeyboardText( + JNIEnv* env, jclass clazz, jstring j_text); + +JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SubmitInlineKeyboardInput( + JNIEnv* env, jclass clazz, jint j_key_code); + +#ifdef __cplusplus +} +#endif diff --git a/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml b/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml new file mode 100755 index 000000000..9f49c133a --- /dev/null +++ b/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml b/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml new file mode 100755 index 000000000..82fd719db --- /dev/null +++ b/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml b/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml new file mode 100755 index 000000000..5892128f1 --- /dev/null +++ b/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml b/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml new file mode 100755 index 000000000..98e0cf8bd --- /dev/null +++ b/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml b/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml new file mode 100755 index 000000000..77a40a4d1 --- /dev/null +++ b/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml b/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml new file mode 100755 index 000000000..4612aee13 --- /dev/null +++ b/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml b/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml new file mode 100755 index 000000000..c00478946 --- /dev/null +++ b/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml @@ -0,0 +1,21 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100755 index 0000000000000000000000000000000000000000..66ebfa85c475537475e6deb531425e2b3ce3d611 GIT binary patch literal 46179 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 literal 0 HcmV?d00001 diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100755 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/app/src/main/res/drawable/default_icon.jpg b/src/android/app/src/main/res/drawable/default_icon.jpg new file mode 100755 index 0000000000000000000000000000000000000000..859caf4af3f533cb3a7dd1db0ca04c4479693ce5 GIT binary patch literal 6285 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 + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml b/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml new file mode 100755 index 000000000..5eeb51dbe --- /dev/null +++ b/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml b/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml new file mode 100755 index 000000000..520fd447c --- /dev/null +++ b/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_a.xml b/src/android/app/src/main/res/drawable/facebutton_a.xml new file mode 100755 index 000000000..668652edb --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_a.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml new file mode 100755 index 000000000..4fbe06962 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_b.xml b/src/android/app/src/main/res/drawable/facebutton_b.xml new file mode 100755 index 000000000..8912219ca --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_b.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml new file mode 100755 index 000000000..012abeaf1 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_home.xml b/src/android/app/src/main/res/drawable/facebutton_home.xml new file mode 100755 index 000000000..03596ec2e --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_home.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml new file mode 100755 index 000000000..cde7c6a9e --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_minus.xml b/src/android/app/src/main/res/drawable/facebutton_minus.xml new file mode 100755 index 000000000..4296b4fcc --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_minus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml new file mode 100755 index 000000000..628027841 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_plus.xml b/src/android/app/src/main/res/drawable/facebutton_plus.xml new file mode 100755 index 000000000..43ae14365 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_plus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml new file mode 100755 index 000000000..c510e136e --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml @@ -0,0 +1,9 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_screenshot.xml b/src/android/app/src/main/res/drawable/facebutton_screenshot.xml new file mode 100755 index 000000000..984b4fd02 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_screenshot.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml new file mode 100755 index 000000000..fd2e44294 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_x.xml b/src/android/app/src/main/res/drawable/facebutton_x.xml new file mode 100755 index 000000000..43fdd14c4 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_x.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml new file mode 100755 index 000000000..a9ba49169 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_y.xml b/src/android/app/src/main/res/drawable/facebutton_y.xml new file mode 100755 index 000000000..980be3b2e --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_y.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml new file mode 100755 index 000000000..320d63897 --- /dev/null +++ b/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml new file mode 100755 index 000000000..f7deb2532 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_arrow_forward.xml b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml new file mode 100755 index 000000000..3b85a3e2c --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_back.xml b/src/android/app/src/main/res/drawable/ic_back.xml new file mode 100755 index 000000000..f99fea719 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_cartridge.xml b/src/android/app/src/main/res/drawable/ic_cartridge.xml new file mode 100755 index 000000000..b332d9c0a --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_cartridge.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml b/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml new file mode 100755 index 000000000..cc35d7eff --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_check.xml b/src/android/app/src/main/res/drawable/ic_check.xml new file mode 100755 index 000000000..04b89abf2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_check_circle.xml b/src/android/app/src/main/res/drawable/ic_check_circle.xml new file mode 100755 index 000000000..49e6ecd71 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_clear.xml b/src/android/app/src/main/res/drawable/ic_clear.xml new file mode 100755 index 000000000..b6edb1d32 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_clear.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_controller.xml b/src/android/app/src/main/res/drawable/ic_controller.xml new file mode 100755 index 000000000..060cd9ae2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_diamond.xml b/src/android/app/src/main/res/drawable/ic_diamond.xml new file mode 100755 index 000000000..3896e12e4 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_diamond.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_discord.xml b/src/android/app/src/main/res/drawable/ic_discord.xml new file mode 100755 index 000000000..7a9c6ba79 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_exit.xml b/src/android/app/src/main/res/drawable/ic_exit.xml new file mode 100755 index 000000000..a55a1d387 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_exit.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_folder_open.xml b/src/android/app/src/main/res/drawable/ic_folder_open.xml new file mode 100755 index 000000000..7958fdaec --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_folder_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_github.xml b/src/android/app/src/main/res/drawable/ic_github.xml new file mode 100755 index 000000000..c2ee43803 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_github.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_icon_bg.xml b/src/android/app/src/main/res/drawable/ic_icon_bg.xml new file mode 100755 index 000000000..df62dde92 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_icon_bg.xml @@ -0,0 +1,751 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_info_outline.xml b/src/android/app/src/main/res/drawable/ic_info_outline.xml new file mode 100755 index 000000000..92ae0eeaf --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_info_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_install.xml b/src/android/app/src/main/res/drawable/ic_install.xml new file mode 100755 index 000000000..01f2de3da --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_install.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_key.xml b/src/android/app/src/main/res/drawable/ic_key.xml new file mode 100755 index 000000000..a3943634f --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_launcher.xml b/src/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100755 index 000000000..3bb60fdfb --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_nfc.xml b/src/android/app/src/main/res/drawable/ic_nfc.xml new file mode 100755 index 000000000..3dacf798b --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_nfc.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_notification.xml b/src/android/app/src/main/res/drawable/ic_notification.xml new file mode 100755 index 000000000..b413f7585 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_options.xml b/src/android/app/src/main/res/drawable/ic_options.xml new file mode 100755 index 000000000..91d52f1b8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_options.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_palette.xml b/src/android/app/src/main/res/drawable/ic_palette.xml new file mode 100755 index 000000000..43daec1ff --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_palette.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_pause.xml b/src/android/app/src/main/res/drawable/ic_pause.xml new file mode 100755 index 000000000..adb3ababc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_play.xml b/src/android/app/src/main/res/drawable/ic_play.xml new file mode 100755 index 000000000..7f01dc599 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml new file mode 100755 index 000000000..bb0726851 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_settings.xml b/src/android/app/src/main/res/drawable/ic_settings.xml new file mode 100755 index 000000000..e527f85fc --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_settings_outline.xml b/src/android/app/src/main/res/drawable/ic_settings_outline.xml new file mode 100755 index 000000000..13b2745bf --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_settings_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_unlock.xml b/src/android/app/src/main/res/drawable/ic_unlock.xml new file mode 100755 index 000000000..40952cbc5 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_unlock.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_website.xml b/src/android/app/src/main/res/drawable/ic_website.xml new file mode 100755 index 000000000..f35b84a7c --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_website.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_yuzu.xml b/src/android/app/src/main/res/drawable/ic_yuzu.xml new file mode 100755 index 000000000..5e2a8efd0 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_yuzu.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_full.xml b/src/android/app/src/main/res/drawable/ic_yuzu_full.xml new file mode 100755 index 000000000..04e458400 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_yuzu_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_title.xml b/src/android/app/src/main/res/drawable/ic_yuzu_title.xml new file mode 100755 index 000000000..b733e5248 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_yuzu_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/joystick.xml b/src/android/app/src/main/res/drawable/joystick.xml new file mode 100755 index 000000000..bdd071212 --- /dev/null +++ b/src/android/app/src/main/res/drawable/joystick.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/joystick_depressed.xml b/src/android/app/src/main/res/drawable/joystick_depressed.xml new file mode 100755 index 000000000..ad51d73ce --- /dev/null +++ b/src/android/app/src/main/res/drawable/joystick_depressed.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/joystick_range.xml b/src/android/app/src/main/res/drawable/joystick_range.xml new file mode 100755 index 000000000..f6282b5c8 --- /dev/null +++ b/src/android/app/src/main/res/drawable/joystick_range.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/l_shoulder.xml b/src/android/app/src/main/res/drawable/l_shoulder.xml new file mode 100755 index 000000000..28f9a9950 --- /dev/null +++ b/src/android/app/src/main/res/drawable/l_shoulder.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml b/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml new file mode 100755 index 000000000..2f9a1fd7e --- /dev/null +++ b/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/premium_background.xml b/src/android/app/src/main/res/drawable/premium_background.xml new file mode 100755 index 000000000..c9c41ddbe --- /dev/null +++ b/src/android/app/src/main/res/drawable/premium_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/r_shoulder.xml b/src/android/app/src/main/res/drawable/r_shoulder.xml new file mode 100755 index 000000000..97731cad2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/r_shoulder.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml b/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml new file mode 100755 index 000000000..e3aa46aa1 --- /dev/null +++ b/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml @@ -0,0 +1,8 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/selector_cartridge.xml b/src/android/app/src/main/res/drawable/selector_cartridge.xml new file mode 100755 index 000000000..85c918dae --- /dev/null +++ b/src/android/app/src/main/res/drawable/selector_cartridge.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/selector_settings.xml b/src/android/app/src/main/res/drawable/selector_settings.xml new file mode 100755 index 000000000..23748feb0 --- /dev/null +++ b/src/android/app/src/main/res/drawable/selector_settings.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/zl_trigger.xml b/src/android/app/src/main/res/drawable/zl_trigger.xml new file mode 100755 index 000000000..436461c3b --- /dev/null +++ b/src/android/app/src/main/res/drawable/zl_trigger.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml b/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml new file mode 100755 index 000000000..00393c04d --- /dev/null +++ b/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/zr_trigger.xml b/src/android/app/src/main/res/drawable/zr_trigger.xml new file mode 100755 index 000000000..2b3a92184 --- /dev/null +++ b/src/android/app/src/main/res/drawable/zr_trigger.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml b/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml new file mode 100755 index 000000000..8a9ee5036 --- /dev/null +++ b/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/src/android/app/src/main/res/layout-w600dp/activity_main.xml b/src/android/app/src/main/res/layout-w600dp/activity_main.xml new file mode 100755 index 000000000..74bee872e --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/activity_main.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml new file mode 100755 index 000000000..cbe631d88 --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout-w600dp/page_setup.xml b/src/android/app/src/main/res/layout-w600dp/page_setup.xml new file mode 100755 index 000000000..e1c26b2f8 --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/page_setup.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml new file mode 100755 index 000000000..f6360a65b --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml new file mode 100755 index 000000000..ad426457f --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml new file mode 100755 index 000000000..14ae83b04 --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml new file mode 100755 index 000000000..469ad9edf --- /dev/null +++ b/src/android/app/src/main/res/layout/card_game.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/card_home_option.xml b/src/android/app/src/main/res/layout/card_home_option.xml new file mode 100755 index 000000000..dc289db17 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_home_option.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_edit_text.xml b/src/android/app/src/main/res/layout/dialog_edit_text.xml new file mode 100755 index 000000000..58b905d71 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_edit_text.xml @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml new file mode 100755 index 000000000..d17711a65 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_slider.xml b/src/android/app/src/main/res/layout/dialog_slider.xml new file mode 100755 index 000000000..8c84cb606 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_slider.xml @@ -0,0 +1,37 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml new file mode 100755 index 000000000..549f00aa5 --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_about.xml @@ -0,0 +1,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +