From fa08df21a50f7de06f39333bd441d2f44c9f7ed8 Mon Sep 17 00:00:00 2001 From: Charles Lombardo Date: Fri, 10 Nov 2023 18:16:54 -0500 Subject: [PATCH] Android UI Overhaul Part 1 (#7108) * android: Android 14 support * android: New home UI flow Port of the yuzu-android home UI with a few Citra specific tweaks. A few important things to note - New and existing Citra users will be guided through the new setup flow - Existing game directory location is discarded and will have to be reselected - Protections around making sure the user has selected a user directory were reworked to fit this new UI. I removed async directory init and DirectoryStateReceivers and check during MainActivity's onResume callback. - Removed Citra premium. The light/dark theme is now available for everyone. * android: New blue app theme * android: Extend UI into status/navigation bar area * android: Remove yellow theme specific styles * android: Disable status/navigation bar contrast enforcement We handle it ourselves so there's no need to use a contrasty background on the system bars * android: GPU Driver Manager Includes a rewrite of FileUtil with some helper functions for the manager * android: Rework NativeLibrary in Kotlin Besides the rewrite this cleans up the alert dialogs that are used for system errors. Generally removes unused JNI code and makes things a little more consistent. * android: Home menu support + downloader * android: Enable minify and resource shrinking * android: Remove premium page and expose texture filtering modes * android: Update AGP to 8.1.2 * android: Don't display emulation in cutout area We don't currently handle the notch properly in the emulation fragment so just don't render under it for now. * android: native.cpp ClangFormat fixes * core: SystemTitles: Include std::optional Without it, the android build would fail * vk: android: Properly override GetDriverLibrary * vk_instance: Blacklist timeline semaphore ext on turnip * vk_platform: Hardcode apiVersion to VK_API_VERSION_1_3 * android: native: Use const where applicable * android: native: Array pointer access style fix * android: Share relevant log Shares the old log if it exists and you haven't booted a game yet and shares the current log if you have booted a game. * android: Apply dark theme color for software keyboard text --------- Co-authored-by: GPUCode --- src/android/app/build.gradle.kts | 81 +- src/android/app/proguard-rules.pro | 40 +- src/android/app/src/main/AndroidManifest.xml | 16 +- .../org/citra/citra_emu/CitraApplication.java | 76 -- .../org/citra/citra_emu/CitraApplication.kt | 67 ++ .../org/citra/citra_emu/NativeLibrary.java | 720 ----------------- .../java/org/citra/citra_emu/NativeLibrary.kt | 728 ++++++++++++++++++ .../activities/EmulationActivity.java | 91 +-- .../citra/citra_emu/adapters/DriverAdapter.kt | 119 +++ .../citra/citra_emu/adapters/GameAdapter.java | 261 ------- .../citra/citra_emu/adapters/GameAdapter.kt | 203 +++++ .../citra_emu/adapters/HomeSettingAdapter.kt | 112 +++ .../citra_emu/adapters/LicenseAdapter.kt | 55 ++ .../citra/citra_emu/adapters/SetupAdapter.kt | 87 +++ .../citra/citra_emu/applets/MiiSelector.java | 3 + .../citra_emu/applets/SoftwareKeyboard.java | 19 +- .../camera/StillImageCameraHelper.java | 3 + .../dialogs/CitraDirectoryDialog.java | 91 --- .../dialogs/CopyDirProgressDialog.java | 61 -- .../features/cheats/ui/CheatsActivity.java | 3 +- .../features/settings/model/Settings.java | 19 +- .../settings/model/view/CheckBoxSetting.java | 2 +- .../model/view/InputBindingSetting.java | 14 +- .../settings/model/view/PremiumHeader.java | 14 - .../view/PremiumSingleChoiceSetting.java | 59 -- .../settings/model/view/SettingsItem.java | 7 - .../settings/ui/SettingsActivity.java | 19 +- .../ui/SettingsActivityPresenter.java | 35 +- .../settings/ui/SettingsActivityView.java | 16 - .../features/settings/ui/SettingsAdapter.java | 85 +- .../ui/SettingsFragmentPresenter.java | 27 +- .../ui/viewholder/PremiumViewHolder.java | 57 -- .../ui/viewholder/SingleChoiceViewHolder.java | 14 - .../features/settings/utils/SettingsFile.java | 28 +- .../citra_emu/fragments/AboutFragment.kt | 123 +++ .../fragments/CitraDirectoryDialogFragment.kt | 92 +++ .../CopyDirProgressDialogFragment.kt | 153 ++++ .../DownloadSystemFilesDialogFragment.kt | 152 ++++ .../fragments/DriverManagerFragment.kt | 182 +++++ .../fragments/DriversLoadingDialogFragment.kt | 76 ++ .../fragments/EmulationFragment.java | 69 +- .../citra_emu/fragments/GamesFragment.kt | 202 +++++ .../fragments/HomeSettingsFragment.kt | 252 ++++++ .../IndeterminateProgressDialogFragment.kt | 137 ++++ .../LicenseBottomSheetDialogFragment.kt | 70 ++ .../citra_emu/fragments/LicensesFragment.kt | 201 +++++ .../fragments/MessageDialogFragment.kt | 86 +++ .../citra_emu/fragments/SearchFragment.kt | 260 +++++++ .../SelectUserDirectoryDialogFragment.kt | 42 + .../citra_emu/fragments/SetupFragment.kt | 481 ++++++++++++ .../fragments/SetupWarningDialogFragment.kt | 87 +++ .../fragments/SystemFilesFragment.kt | 301 ++++++++ .../java/org/citra/citra_emu/model/Game.java | 76 -- .../java/org/citra/citra_emu/model/Game.kt | 59 ++ .../citra/citra_emu/model/GameDatabase.java | 279 ------- .../org/citra/citra_emu/model/GameInfo.java | 37 - .../org/citra/citra_emu/model/GameInfo.kt | 37 + .../citra/citra_emu/model/GameProvider.java | 138 ---- .../org/citra/citra_emu/model/HomeSetting.kt | 19 + .../java/org/citra/citra_emu/model/License.kt | 19 + .../org/citra/citra_emu/model/SetupPage.kt | 31 + .../citra/citra_emu/overlay/InputOverlay.java | 18 +- .../citra/citra_emu/ui/main/MainActivity.java | 334 -------- .../citra/citra_emu/ui/main/MainActivity.kt | 327 ++++++++ .../citra_emu/ui/main/MainPresenter.java | 92 --- .../org/citra/citra_emu/ui/main/MainView.java | 25 - .../ui/platform/PlatformGamesFragment.java | 127 --- .../ui/platform/PlatformGamesPresenter.java | 42 - .../ui/platform/PlatformGamesView.java | 21 - .../citra_emu/utils/AddDirectoryHelper.java | 38 - .../citra/citra_emu/utils/BillingManager.java | 215 ------ .../citra_emu/utils/CiaInstallWorker.java | 17 +- .../citra_emu/utils/CitraDirectoryHelper.java | 87 --- .../citra_emu/utils/CitraDirectoryHelper.kt | 63 ++ .../utils/DirectoryInitialization.java | 189 ----- .../utils/DirectoryInitialization.kt | 163 ++++ .../utils/DirectoryStateReceiver.java | 22 - .../utils/DiskShaderCacheProgress.java | 2 + .../citra/citra_emu/utils/DocumentsTree.java | 300 -------- .../citra/citra_emu/utils/DocumentsTree.kt | 275 +++++++ .../utils/EmulationMenuSettings.java | 2 +- .../org/citra/citra_emu/utils/FileUtil.java | 454 ----------- .../org/citra/citra_emu/utils/FileUtil.kt | 598 ++++++++++++++ .../org/citra/citra_emu/utils/GameHelper.kt | 107 +++ .../utils/GameIconRequestHandler.java | 35 - .../citra/citra_emu/utils/GameIconUtils.kt | 79 ++ .../citra/citra_emu/utils/GpuDriverHelper.kt | 237 ++++++ .../citra_emu/utils/GpuDriverMetadata.kt | 120 +++ .../java/org/citra/citra_emu/utils/Log.java | 3 + .../citra_emu/utils/PermissionsHandler.java | 64 -- .../citra_emu/utils/PermissionsHandler.kt | 50 ++ .../PicassoRoundedCornersTransformation.java | 45 -- .../citra/citra_emu/utils/PicassoUtils.java | 30 - .../citra_emu/utils/SerializableHelper.kt | 42 + .../citra/citra_emu/utils/StartupHandler.java | 56 -- .../org/citra/citra_emu/utils/ThemeUtil.java | 90 --- .../org/citra/citra_emu/utils/ThemeUtil.kt | 76 ++ .../org/citra/citra_emu/utils/ViewUtils.kt | 36 + .../citra_emu/viewmodel/DriverViewModel.kt | 150 ++++ .../citra_emu/viewmodel/GamesViewModel.kt | 121 +++ .../citra_emu/viewmodel/HomeViewModel.kt | 114 +++ .../viewmodel/SystemFilesViewModel.kt | 139 ++++ .../citra_emu/viewmodel/TaskViewModel.kt | 59 ++ src/android/app/src/main/jni/CMakeLists.txt | 1 - src/android/app/src/main/jni/config.cpp | 4 +- .../src/main/jni/emu_window/emu_window_vk.cpp | 4 + .../src/main/jni/emu_window/emu_window_vk.h | 2 + src/android/app/src/main/jni/game_info.cpp | 9 + src/android/app/src/main/jni/id_cache.cpp | 38 +- src/android/app/src/main/jni/native.cpp | 300 +++++--- src/android/app/src/main/jni/native.h | 167 ---- src/android/app/src/main/jni/ndk_motion.cpp | 1 - .../main/res/drawable/ic_arrow_forward.xml | 10 + .../app/src/main/res/drawable/ic_camera.xml | 12 + .../app/src/main/res/drawable/ic_check.xml | 9 + .../src/main/res/drawable/ic_citra_full.xml | 310 ++++++++ .../app/src/main/res/drawable/ic_clear.xml | 9 + .../src/main/res/drawable/ic_controller.xml | 9 + .../res/drawable/ic_controller_outline.xml | 15 + .../app/src/main/res/drawable/ic_delete.xml | 9 + .../app/src/main/res/drawable/ic_discord.xml | 10 + .../app/src/main/res/drawable/ic_github.xml | 10 + .../app/src/main/res/drawable/ic_home.xml | 9 + .../src/main/res/drawable/ic_info_outline.xml | 9 + .../main/res/drawable/ic_install_driver.xml | 12 + .../src/main/res/drawable/ic_microphone.xml | 9 + .../app/src/main/res/drawable/ic_more.xml | 9 + .../src/main/res/drawable/ic_notification.xml | 9 + .../app/src/main/res/drawable/ic_search.xml | 9 + .../main/res/drawable/ic_settings_outline.xml | 9 + .../app/src/main/res/drawable/ic_share.xml | 9 + .../main/res/drawable/ic_system_update.xml | 9 + .../app/src/main/res/drawable/ic_website.xml | 9 + .../main/res/drawable/selector_controller.xml | 5 + .../main/res/drawable/selector_settings.xml | 5 + .../main/res/layout-w600dp/activity_main.xml | 58 ++ .../main/res/layout-w600dp/fragment_setup.xml | 46 ++ .../layout-w600dp/fragment_system_files.xml | 219 ++++++ .../app/src/main/res/layout/activity_main.xml | 64 +- .../main/res/layout/card_driver_option.xml | 89 +++ .../app/src/main/res/layout/card_game.xml | 99 +-- .../src/main/res/layout/card_home_option.xml | 76 ++ .../res/layout/dialog_citra_directory.xml | 40 +- .../src/main/res/layout/dialog_copy_dir.xml | 30 + .../src/main/res/layout/dialog_license.xml | 64 ++ .../main/res/layout/dialog_progress_bar.xml | 26 +- .../src/main/res/layout/fragment_about.xml | 232 ++++++ .../res/layout/fragment_driver_manager.xml | 48 ++ .../src/main/res/layout/fragment_games.xml | 34 + .../app/src/main/res/layout/fragment_grid.xml | 37 - .../res/layout/fragment_home_settings.xml | 34 + .../src/main/res/layout/fragment_licenses.xml | 31 + .../src/main/res/layout/fragment_search.xml | 175 +++++ .../src/main/res/layout/fragment_setup.xml | 46 ++ .../main/res/layout/fragment_system_files.xml | 201 +++++ .../app/src/main/res/layout/page_setup.xml | 92 +++ .../main/res/menu-w600dp/menu_navigation.xml | 19 + .../app/src/main/res/menu/menu_game_grid.xml | 39 - .../app/src/main/res/menu/menu_navigation.xml | 19 + .../main/res/navigation/home_navigation.xml | 78 ++ .../main/res/values-night/citra_colors.xml | 50 +- .../app/src/main/res/values-v29/themes.xml | 9 + .../src/main/res/values-w1000dp/integers.xml | 4 - .../src/main/res/values-w1050dp/dimens.xml | 6 - .../app/src/main/res/values-w600dp/bools.xml | 4 + .../app/src/main/res/values-w600dp/dimens.xml | 5 + .../integers.xml | 2 +- .../src/main/res/values-w750dp/integers.xml | 4 - .../app/src/main/res/values-w820dp/dimens.xml | 5 - .../app/src/main/res/values/arrays.xml | 30 + src/android/app/src/main/res/values/bools.xml | 4 + .../app/src/main/res/values/citra_colors.xml | 42 +- .../app/src/main/res/values/dimens.xml | 7 + .../app/src/main/res/values/licenses.xml | 398 ++++++++++ .../app/src/main/res/values/strings.xml | 160 +++- .../app/src/main/res/values/styles.xml | 31 - .../app/src/main/res/values/themes.xml | 15 +- src/android/build.gradle.kts | 14 +- src/android/settings.gradle.kts | 1 - src/core/system_titles.h | 1 + .../renderer_vulkan/vk_instance.cpp | 6 +- .../renderer_vulkan/vk_platform.cpp | 2 +- 182 files changed, 10511 insertions(+), 5183 deletions(-) delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/Game.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/License.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java delete mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt delete mode 100644 src/android/app/src/main/jni/native.h create mode 100644 src/android/app/src/main/res/drawable/ic_arrow_forward.xml create mode 100644 src/android/app/src/main/res/drawable/ic_camera.xml create mode 100644 src/android/app/src/main/res/drawable/ic_check.xml create mode 100644 src/android/app/src/main/res/drawable/ic_citra_full.xml create mode 100644 src/android/app/src/main/res/drawable/ic_clear.xml create mode 100644 src/android/app/src/main/res/drawable/ic_controller.xml create mode 100644 src/android/app/src/main/res/drawable/ic_controller_outline.xml create mode 100644 src/android/app/src/main/res/drawable/ic_delete.xml create mode 100644 src/android/app/src/main/res/drawable/ic_discord.xml create mode 100644 src/android/app/src/main/res/drawable/ic_github.xml create mode 100644 src/android/app/src/main/res/drawable/ic_home.xml create mode 100644 src/android/app/src/main/res/drawable/ic_info_outline.xml create mode 100644 src/android/app/src/main/res/drawable/ic_install_driver.xml create mode 100644 src/android/app/src/main/res/drawable/ic_microphone.xml create mode 100644 src/android/app/src/main/res/drawable/ic_more.xml create mode 100644 src/android/app/src/main/res/drawable/ic_notification.xml create mode 100644 src/android/app/src/main/res/drawable/ic_search.xml create mode 100644 src/android/app/src/main/res/drawable/ic_settings_outline.xml create mode 100644 src/android/app/src/main/res/drawable/ic_share.xml create mode 100644 src/android/app/src/main/res/drawable/ic_system_update.xml create mode 100644 src/android/app/src/main/res/drawable/ic_website.xml create mode 100644 src/android/app/src/main/res/drawable/selector_controller.xml create mode 100644 src/android/app/src/main/res/drawable/selector_settings.xml create mode 100644 src/android/app/src/main/res/layout-w600dp/activity_main.xml create mode 100644 src/android/app/src/main/res/layout-w600dp/fragment_setup.xml create mode 100644 src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml create mode 100644 src/android/app/src/main/res/layout/card_driver_option.xml create mode 100644 src/android/app/src/main/res/layout/card_home_option.xml create mode 100644 src/android/app/src/main/res/layout/dialog_copy_dir.xml create mode 100644 src/android/app/src/main/res/layout/dialog_license.xml create mode 100644 src/android/app/src/main/res/layout/fragment_about.xml create mode 100644 src/android/app/src/main/res/layout/fragment_driver_manager.xml create mode 100644 src/android/app/src/main/res/layout/fragment_games.xml delete mode 100644 src/android/app/src/main/res/layout/fragment_grid.xml create mode 100644 src/android/app/src/main/res/layout/fragment_home_settings.xml create mode 100644 src/android/app/src/main/res/layout/fragment_licenses.xml create mode 100644 src/android/app/src/main/res/layout/fragment_search.xml create mode 100644 src/android/app/src/main/res/layout/fragment_setup.xml create mode 100644 src/android/app/src/main/res/layout/fragment_system_files.xml create mode 100644 src/android/app/src/main/res/layout/page_setup.xml create mode 100644 src/android/app/src/main/res/menu-w600dp/menu_navigation.xml delete mode 100644 src/android/app/src/main/res/menu/menu_game_grid.xml create mode 100644 src/android/app/src/main/res/menu/menu_navigation.xml create mode 100644 src/android/app/src/main/res/navigation/home_navigation.xml create mode 100644 src/android/app/src/main/res/values-v29/themes.xml delete mode 100644 src/android/app/src/main/res/values-w1000dp/integers.xml delete mode 100644 src/android/app/src/main/res/values-w1050dp/dimens.xml create mode 100644 src/android/app/src/main/res/values-w600dp/bools.xml create mode 100644 src/android/app/src/main/res/values-w600dp/dimens.xml rename src/android/app/src/main/res/{values-w500dp => values-w600dp}/integers.xml (88%) delete mode 100644 src/android/app/src/main/res/values-w750dp/integers.xml delete mode 100644 src/android/app/src/main/res/values-w820dp/dimens.xml create mode 100644 src/android/app/src/main/res/values/bools.xml create mode 100644 src/android/app/src/main/res/values/licenses.xml diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts index 36f215433..3a34cbf0f 100644 --- a/src/android/app/build.gradle.kts +++ b/src/android/app/build.gradle.kts @@ -2,15 +2,18 @@ // Licensed under GPLv2 or any later version // Refer to the license.txt file included. +import android.databinding.tool.ext.capitalizeUS +import de.undercouch.gradle.tasks.download.Download + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("de.undercouch.download") version "5.5.0" + id("kotlin-parcelize") + kotlin("plugin.serialization") version "1.8.21" + id("androidx.navigation.safeargs.kotlin") } -import android.databinding.tool.ext.capitalizeUS -import de.undercouch.gradle.tasks.download.Download - /** * 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 @@ -25,7 +28,7 @@ val downloadedJniLibsPath = "${buildDir}/downloadedJniLibs" android { namespace = "org.citra.citra_emu" - compileSdkVersion = "android-33" + compileSdkVersion = "android-34" ndkVersion = "25.2.9519653" compileOptions { @@ -37,6 +40,11 @@ android { jvmTarget = "17" } + packaging { + // This is necessary for libadrenotools custom driver loading + jniLibs.useLegacyPackaging = true + } + buildFeatures { viewBinding = true } @@ -51,7 +59,7 @@ android { // TODO If this is ever modified, change application_id in strings.xml applicationId = "org.citra.citra_emu" minSdk = 28 - targetSdk = 33 + targetSdk = 34 versionCode = autoVersion versionName = getGitVersion() @@ -69,6 +77,9 @@ android { ) } } + + buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") + buildConfigField("String", "BRANCH", "\"${getBranch()}\"") } val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE") @@ -92,6 +103,12 @@ android { } else { signingConfigs.getByName("debug") } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) } // builds a release build that doesn't need signing @@ -101,9 +118,15 @@ android { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" signingConfig = signingConfigs.getByName("debug") - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true isDebuggable = true isJniDebuggable = true + proguardFiles( + getDefaultProguardFile("proguard-android.txt"), + "proguard-rules.pro" + ) + isDefault = true } // Signed by debug key disallowing distribution on Play Store. @@ -145,8 +168,9 @@ android { } dependencies { - implementation("androidx.activity:activity-ktx:1.7.2") - implementation("androidx.fragment:fragment-ktx:1.6.0") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.activity:activity-ktx:1.8.0") + implementation("androidx.fragment:fragment-ktx:1.6.2") implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") @@ -158,15 +182,14 @@ dependencies { // For loading huge screenshots from the disk. implementation("com.squareup.picasso:picasso:2.71828") - // Allows FRP-style asynchronous operations in Android. - implementation("io.reactivex:rxandroid:1.2.1") - implementation("org.ini4j:ini4j:0.5.4") - implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") - - // Please don't upgrade the billing library as the newer version is not GPL-compatible - implementation("com.android.billingclient:billing:2.0.3") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.5") + implementation("androidx.navigation:navigation-ui-ktx:2.7.5") + implementation("info.debatty:java-string-similarity:2.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("io.coil-kt:coil:2.2.2") } // Download Vulkan Validation Layers from the KhronosGroup GitHub. @@ -216,6 +239,34 @@ fun getGitVersion(): String { return versionName } +fun getGitHash(): String = + runGitCommand(ProcessBuilder("git", "rev-parse", "--short", "HEAD")) ?: "dummy-hash" + +fun getBranch(): String = + runGitCommand(ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")) ?: "dummy-branch" + +fun runGitCommand(command: ProcessBuilder) : String? { + try { + command.directory(project.rootDir) + val process = command.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") + return null + } + } catch (e: Exception) { + logger.error("$e: Cannot find git") + return null + } +} + android.applicationVariants.configureEach { val variant = this val capitalizedName = variant.name.capitalizeUS() diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro index f1b424510..b0a4f4ca6 100644 --- a/src/android/app/proguard-rules.pro +++ b/src/android/app/proguard-rules.pro @@ -1,21 +1,25 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html +# Copyright 2023 Citra Emulator Project +# Licensed under GPLv2 or any later version +# Refer to the license.txt file included. -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# To get usable stack traces +-dontobfuscate -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +# Prevents crashing when using Wini +-keep class org.ini4j.spi.IniParser +-keep class org.ini4j.spi.IniBuilder +-keep class org.ini4j.spi.IniFormatter -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile +# 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/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml index 1e8494682..416321ce8 100644 --- a/src/android/app/src/main/AndroidManifest.xml +++ b/src/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,7 @@ + + android:exported="true"> @@ -68,21 +68,15 @@ android:theme="@style/Theme.Citra.Main" android:launchMode="singleTop"/> - + + + - - - - diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java deleted file mode 100644 index b57226070..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -package org.citra.citra_emu; - -import android.app.Application; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.content.Context; -import android.os.Build; - -import org.citra.citra_emu.model.GameDatabase; -import org.citra.citra_emu.utils.DirectoryInitialization; -import org.citra.citra_emu.utils.DocumentsTree; -import org.citra.citra_emu.utils.PermissionsHandler; - -public class CitraApplication extends Application { - public static GameDatabase databaseHelper; - public static DocumentsTree documentsTree; - private static CitraApplication application; - - private void createNotificationChannel() { - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - return; - } - NotificationManager notificationManager = getSystemService(NotificationManager.class); - { - // General notification - CharSequence name = getString(R.string.app_notification_channel_name); - String description = getString(R.string.app_notification_channel_description); - NotificationChannel channel = new NotificationChannel( - getString(R.string.app_notification_channel_id), name, - NotificationManager.IMPORTANCE_LOW); - channel.setDescription(description); - channel.setSound(null, null); - channel.setVibrationPattern(null); - - notificationManager.createNotificationChannel(channel); - } - { - // CIA Install notifications - NotificationChannel channel = new NotificationChannel( - getString(R.string.cia_install_notification_channel_id), - getString(R.string.cia_install_notification_channel_name), - NotificationManager.IMPORTANCE_DEFAULT); - channel.setDescription(getString(R.string.cia_install_notification_channel_description)); - channel.setSound(null, null); - channel.setVibrationPattern(null); - - notificationManager.createNotificationChannel(channel); - } - } - - @Override - public void onCreate() { - super.onCreate(); - application = this; - documentsTree = new DocumentsTree(); - - if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { - DirectoryInitialization.start(getApplicationContext()); - } - - NativeLibrary.LogDeviceInfo(); - createNotificationChannel(); - - databaseHelper = new GameDatabase(this); - } - - public static Context getAppContext() { - return application.getApplicationContext(); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt new file mode 100644 index 000000000..c414d4246 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.kt @@ -0,0 +1,67 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu + +import android.annotation.SuppressLint +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.citra.citra_emu.utils.DirectoryInitialization +import org.citra.citra_emu.utils.DocumentsTree +import org.citra.citra_emu.utils.GpuDriverHelper +import org.citra.citra_emu.utils.PermissionsHandler + +class CitraApplication : Application() { + private fun createNotificationChannel() { + with(getSystemService(NotificationManager::class.java)) { + // General notification + val name: CharSequence = getString(R.string.app_notification_channel_name) + val description = getString(R.string.app_notification_channel_description) + val generalChannel = NotificationChannel( + getString(R.string.app_notification_channel_id), + name, + NotificationManager.IMPORTANCE_LOW + ) + generalChannel.description = description + generalChannel.setSound(null, null) + generalChannel.vibrationPattern = null + createNotificationChannel(generalChannel) + + // CIA Install notifications + val ciaChannel = NotificationChannel( + getString(R.string.cia_install_notification_channel_id), + getString(R.string.cia_install_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ) + ciaChannel.description = + getString(R.string.cia_install_notification_channel_description) + ciaChannel.setSound(null, null) + ciaChannel.vibrationPattern = null + createNotificationChannel(ciaChannel) + } + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + if (PermissionsHandler.hasWriteAccess(applicationContext)) { + DirectoryInitialization.start() + } + + NativeLibrary.logDeviceInfo() + createNotificationChannel() + } + + companion object { + private var application: CitraApplication? = null + + val appContext: Context get() = application!!.applicationContext + + @SuppressLint("StaticFieldLeak") + lateinit var documentsTree: DocumentsTree + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java deleted file mode 100644 index 2e431eb92..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java +++ /dev/null @@ -1,720 +0,0 @@ -/* - * Copyright 2013 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu; - -import android.app.Activity; -import android.app.Dialog; -import android.content.pm.PackageManager; -import android.content.res.Configuration; -import android.os.Bundle; -import android.text.Html; -import android.text.method.LinkMovementMethod; -import android.view.Surface; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.FrameLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.DialogFragment; - -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.applets.SoftwareKeyboard; -import org.citra.citra_emu.utils.EmulationMenuSettings; -import org.citra.citra_emu.utils.FileUtil; -import org.citra.citra_emu.utils.Log; -import org.citra.citra_emu.utils.PermissionsHandler; - -import java.lang.ref.WeakReference; -import java.util.Date; -import java.util.Objects; - -import static android.Manifest.permission.CAMERA; -import static android.Manifest.permission.RECORD_AUDIO; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -/** - * Class which contains methods that interact - * with the native side of the Citra code. - */ -public final class NativeLibrary { - /** - * Default touchscreen device - */ - public static final String TouchScreenDevice = "Touchscreen"; - public static WeakReference sEmulationActivity = new WeakReference<>(null); - - private static boolean alertResult = false; - private static String alertPromptResult = ""; - private static int alertPromptButton = 0; - private static final Object alertPromptLock = new Object(); - private static boolean alertPromptInProgress = false; - private static String alertPromptCaption = ""; - private static int alertPromptButtonConfig = 0; - private static EditText alertPromptEditText = null; - - static { - try { - System.loadLibrary("citra-android"); - } catch (UnsatisfiedLinkError ex) { - Log.error("[NativeLibrary] " + ex.toString()); - } - } - - private NativeLibrary() { - // Disallows instantiation. - } - - /** - * Handles button press events for a gamepad. - * - * @param Device The input descriptor of the gamepad. - * @param Button Key code identifying which button was pressed. - * @param Action Mask identifying which action is happening (button pressed down, or button released). - * @return If we handled the button press. - */ - public static native boolean onGamePadEvent(String Device, int Button, int Action); - - /** - * Handles gamepad movement events. - * - * @param Device The device ID of the gamepad. - * @param Axis The axis ID - * @param x_axis The value of the x-axis represented by the given ID. - * @param y_axis The value of the y-axis represented by the given ID - */ - public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis); - - /** - * Handles gamepad movement events. - * - * @param Device The device ID of the gamepad. - * @param Axis_id The axis ID - * @param axis_val The value of the axis represented by the given ID. - */ - public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val); - - /** - * Handles touch events. - * - * @param x_axis The value of the x-axis. - * @param y_axis The value of the y-axis - * @param pressed To identify if the touch held down or released. - * @return true if the pointer is within the touchscreen - */ - public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed); - - /** - * Handles touch movement. - * - * @param x_axis The value of the instantaneous x-axis. - * @param y_axis The value of the instantaneous y-axis. - */ - public static native void onTouchMoved(float x_axis, float y_axis); - - public static native void ReloadSettings(); - - public static native String GetUserSetting(String gameID, String Section, String Key); - - public static native void SetUserSetting(String gameID, String Section, String Key, String Value); - - public static native void InitGameIni(String gameID); - - public static native long GetTitleId(String filename); - - public static native String GetGitRevision(); - - /** - * Sets the current working user directory - * If not set, it auto-detects a location - */ - public static native void SetUserDirectory(String directory); - - public static native String[] GetInstalledGamePaths(); - - // Create the config.ini file. - public static native void CreateConfigFile(); - - public static native void CreateLogFile(); - - public static native void LogUserDirectory(String directory); - - public static native int DefaultCPUCore(); - - /** - * Begins emulation. - */ - public static native void Run(String path); - - public static native String[] GetTextureFilterNames(); - - /** - * Begins emulation from the specified savestate. - */ - public static native void Run(String path, String savestatePath, boolean deleteSavestate); - - // Surface Handling - public static native void SurfaceChanged(Surface surf); - - public static native void SurfaceDestroyed(); - - public static native void DoFrame(); - - /** - * Unpauses emulation from a paused state. - */ - public static native void UnPauseEmulation(); - - /** - * Pauses emulation. - */ - public static native void PauseEmulation(); - - /** - * Stops emulation. - */ - public static native void StopEmulation(); - - /** - * Returns true if emulation is running (or is paused). - */ - public static native boolean IsRunning(); - - /** - * Returns the title ID of the currently running title, or 0 on failure. - */ - public static native long GetRunningTitleId(); - - /** - * Returns the performance stats for the current game - **/ - public static native double[] GetPerfStats(); - - /** - * Notifies the core emulation that the orientation has changed. - */ - public static native void NotifyOrientationChange(int layout_option, int rotation); - - /** - * Swaps the top and bottom screens. - */ - public static native void SwapScreens(boolean swap_screens, int rotation); - - public enum CoreError { - ErrorSystemFiles, - ErrorSavestate, - ErrorUnknown, - } - - private static boolean coreErrorAlertResult = false; - private static final Object coreErrorAlertLock = new Object(); - - public static class CoreErrorDialogFragment extends DialogFragment { - static CoreErrorDialogFragment newInstance(String title, String message) { - CoreErrorDialogFragment frag = new CoreErrorDialogFragment(); - Bundle args = new Bundle(); - args.putString("title", title); - args.putString("message", message); - frag.setArguments(args); - return frag; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final Activity emulationActivity = Objects.requireNonNull(getActivity()); - - final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); - final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); - - return new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.continue_button, (dialog, which) -> { - coreErrorAlertResult = true; - synchronized (coreErrorAlertLock) { - coreErrorAlertLock.notify(); - } - }) - .setNegativeButton(R.string.abort_button, (dialog, which) -> { - coreErrorAlertResult = false; - synchronized (coreErrorAlertLock) { - coreErrorAlertLock.notify(); - } - }).setOnDismissListener(dialog -> { - coreErrorAlertResult = true; - synchronized (coreErrorAlertLock) { - coreErrorAlertLock.notify(); - } - }).create(); - } - } - - private static void OnCoreErrorImpl(String title, String message) { - final EmulationActivity emulationActivity = sEmulationActivity.get(); - if (emulationActivity == null) { - Log.error("[NativeLibrary] EmulationActivity not present"); - return; - } - - CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message); - fragment.show(emulationActivity.getSupportFragmentManager(), "coreError"); - } - - /** - * Handles a core error. - * @return true: continue; false: abort - */ - public static boolean OnCoreError(CoreError error, String details) { - final EmulationActivity emulationActivity = sEmulationActivity.get(); - if (emulationActivity == null) { - Log.error("[NativeLibrary] EmulationActivity not present"); - return false; - } - - String title, message; - switch (error) { - case ErrorSystemFiles: { - title = emulationActivity.getString(R.string.system_archive_not_found); - message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details); - break; - } - case ErrorSavestate: { - title = emulationActivity.getString(R.string.save_load_error); - message = details; - break; - } - case ErrorUnknown: { - title = emulationActivity.getString(R.string.fatal_error); - message = emulationActivity.getString(R.string.fatal_error_message); - break; - } - default: { - return true; - } - } - - // Show the AlertDialog on the main thread. - emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message)); - - // Wait for the lock to notify that it is complete. - synchronized (coreErrorAlertLock) { - try { - coreErrorAlertLock.wait(); - } catch (Exception ignored) { - } - } - - return coreErrorAlertResult; - } - - public static boolean isPortraitMode() { - return CitraApplication.getAppContext().getResources().getConfiguration().orientation == - Configuration.ORIENTATION_PORTRAIT; - } - - public static int landscapeScreenLayout() { - return EmulationMenuSettings.getLandscapeScreenLayout(); - } - - public static boolean displayAlertMsg(final String caption, final String text, - final boolean yesNo) { - Log.error("[NativeLibrary] Alert: " + text); - final EmulationActivity emulationActivity = sEmulationActivity.get(); - boolean result = false; - if (emulationActivity == null) { - Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert."); - } else { - // Create object used for waiting. - final Object lock = new Object(); - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(caption) - .setMessage(text); - - // If not yes/no dialog just have one button that dismisses modal, - // otherwise have a yes and no button that sets alertResult accordingly. - if (!yesNo) { - builder - .setCancelable(false) - .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> - { - dialog.dismiss(); - synchronized (lock) { - lock.notify(); - } - }); - } else { - alertResult = false; - - builder - .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> - { - alertResult = true; - dialog.dismiss(); - synchronized (lock) { - lock.notify(); - } - }) - .setNegativeButton(android.R.string.no, (dialog, whichButton) -> - { - alertResult = false; - dialog.dismiss(); - synchronized (lock) { - lock.notify(); - } - }); - } - - // Show the AlertDialog on the main thread. - emulationActivity.runOnUiThread(builder::show); - - // Wait for the lock to notify that it is complete. - synchronized (lock) { - try { - lock.wait(); - } catch (Exception e) { - } - } - - if (yesNo) - result = alertResult; - } - return result; - } - - public static void retryDisplayAlertPrompt() { - if (!alertPromptInProgress) { - return; - } - displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show(); - } - - public static String displayAlertPrompt(String caption, String text, int buttonConfig) { - alertPromptCaption = caption; - alertPromptButtonConfig = buttonConfig; - alertPromptInProgress = true; - - // Show the AlertDialog on the main thread - sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show()); - - // Wait for the lock to notify that it is complete - synchronized (alertPromptLock) { - try { - alertPromptLock.wait(); - } catch (Exception e) { - } - } - alertPromptInProgress = false; - - return alertPromptResult; - } - - public static MaterialAlertDialogBuilder displayAlertPromptImpl(String caption, String text, int buttonConfig) { - final EmulationActivity emulationActivity = sEmulationActivity.get(); - alertPromptResult = ""; - alertPromptButton = 0; - - FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin); - - // Set up the input - alertPromptEditText = new EditText(CitraApplication.getAppContext()); - alertPromptEditText.setText(text); - alertPromptEditText.setSingleLine(); - alertPromptEditText.setLayoutParams(params); - - FrameLayout container = new FrameLayout(emulationActivity); - container.addView(alertPromptEditText); - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(caption) - .setView(container) - .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> - { - alertPromptButton = buttonConfig; - alertPromptResult = alertPromptEditText.getText().toString(); - synchronized (alertPromptLock) { - alertPromptLock.notifyAll(); - } - }) - .setOnDismissListener(dialogInterface -> - { - alertPromptResult = ""; - synchronized (alertPromptLock) { - alertPromptLock.notifyAll(); - } - }); - - if (buttonConfig > 0) { - builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> - { - alertPromptResult = ""; - synchronized (alertPromptLock) { - alertPromptLock.notifyAll(); - } - }); - } - - return builder; - } - - public static int alertPromptButton() { - return alertPromptButton; - } - - public static void exitEmulationActivity(int resultCode) { - final int Success = 0; - final int ErrorNotInitialized = 1; - final int ErrorGetLoader = 2; - final int ErrorSystemMode = 3; - final int ErrorLoader = 4; - final int ErrorLoader_ErrorEncrypted = 5; - final int ErrorLoader_ErrorInvalidFormat = 6; - final int ErrorSystemFiles = 7; - final int ShutdownRequested = 11; - final int ErrorUnknown = 12; - - final EmulationActivity emulationActivity = sEmulationActivity.get(); - if (emulationActivity == null) { - Log.warning("[NativeLibrary] EmulationActivity is null, can't exit."); - return; - } - - int captionId = R.string.loader_error_invalid_format; - if (resultCode == ErrorLoader_ErrorEncrypted) { - captionId = R.string.loader_error_encrypted; - } - - MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(emulationActivity) - .setTitle(captionId) - .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY)) - .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish()) - .setOnDismissListener(dialogInterface -> emulationActivity.finish()); - emulationActivity.runOnUiThread(() -> { - AlertDialog alert = builder.create(); - alert.show(); - ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); - }); - } - - public static void setEmulationActivity(EmulationActivity emulationActivity) { - Log.verbose("[NativeLibrary] Registering EmulationActivity."); - sEmulationActivity = new WeakReference<>(emulationActivity); - } - - public static void clearEmulationActivity() { - Log.verbose("[NativeLibrary] Unregistering EmulationActivity."); - - sEmulationActivity.clear(); - } - - private static final Object cameraPermissionLock = new Object(); - private static boolean cameraPermissionGranted = false; - public static final int REQUEST_CODE_NATIVE_CAMERA = 800; - - public static boolean RequestCameraPermission() { - final EmulationActivity emulationActivity = sEmulationActivity.get(); - if (emulationActivity == null) { - Log.error("[NativeLibrary] EmulationActivity not present"); - return false; - } - if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) { - // Permission already granted - return true; - } - emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA); - - // Wait until result is returned - synchronized (cameraPermissionLock) { - try { - cameraPermissionLock.wait(); - } catch (InterruptedException ignored) { - } - } - return cameraPermissionGranted; - } - - public static void CameraPermissionResult(boolean granted) { - cameraPermissionGranted = granted; - synchronized (cameraPermissionLock) { - cameraPermissionLock.notify(); - } - } - - private static final Object micPermissionLock = new Object(); - private static boolean micPermissionGranted = false; - public static final int REQUEST_CODE_NATIVE_MIC = 900; - - public static boolean RequestMicPermission() { - final EmulationActivity emulationActivity = sEmulationActivity.get(); - if (emulationActivity == null) { - Log.error("[NativeLibrary] EmulationActivity not present"); - return false; - } - if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { - // Permission already granted - return true; - } - emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC); - - // Wait until result is returned - synchronized (micPermissionLock) { - try { - micPermissionLock.wait(); - } catch (InterruptedException ignored) { - } - } - return micPermissionGranted; - } - - public static void MicPermissionResult(boolean granted) { - micPermissionGranted = granted; - synchronized (micPermissionLock) { - micPermissionLock.notify(); - } - } - - /// Notifies that the activity is now in foreground and camera devices can now be reloaded - public static native void ReloadCameraDevices(); - - public static native boolean LoadAmiibo(String path); - - public static native void RemoveAmiibo(); - - public static final int SAVESTATE_SLOT_COUNT = 10; - - public static final class SavestateInfo { - public int slot; - public Date time; - } - - @Nullable - public static native SavestateInfo[] GetSavestateInfo(); - - public static native void SaveState(int slot); - public static native void LoadState(int slot); - - /** - * Logs the Citra version, Android version and, CPU. - */ - public static native void LogDeviceInfo(); - - /** - * Button type for use in onTouchEvent - */ - public static final class ButtonType { - public static final int BUTTON_A = 700; - public static final int BUTTON_B = 701; - public static final int BUTTON_X = 702; - public static final int BUTTON_Y = 703; - public static final int BUTTON_START = 704; - public static final int BUTTON_SELECT = 705; - public static final int BUTTON_HOME = 706; - public static final int BUTTON_ZL = 707; - public static final int BUTTON_ZR = 708; - public static final int DPAD_UP = 709; - public static final int DPAD_DOWN = 710; - public static final int DPAD_LEFT = 711; - public static final int DPAD_RIGHT = 712; - public static final int STICK_LEFT = 713; - public static final int STICK_LEFT_UP = 714; - public static final int STICK_LEFT_DOWN = 715; - public static final int STICK_LEFT_LEFT = 716; - public static final int STICK_LEFT_RIGHT = 717; - public static final int STICK_C = 718; - public static final int STICK_C_UP = 719; - public static final int STICK_C_DOWN = 720; - public static final int STICK_C_LEFT = 771; - public static final int STICK_C_RIGHT = 772; - public static final int TRIGGER_L = 773; - public static final int TRIGGER_R = 774; - public static final int DPAD = 780; - public static final int BUTTON_DEBUG = 781; - public static final int BUTTON_GPIO14 = 782; - } - - /** - * Button states - */ - public static final class ButtonState { - public static final int RELEASED = 0; - public static final int PRESSED = 1; - } - public static boolean createFile(String directory, String filename) { - if (FileUtil.isNativePath(directory)) { - return CitraApplication.documentsTree.createFile(directory, filename); - } - return FileUtil.createFile(CitraApplication.getAppContext(), directory, filename) != null; - } - - public static boolean createDir(String directory, String directoryName) { - if (FileUtil.isNativePath(directory)) { - return CitraApplication.documentsTree.createDir(directory, directoryName); - } - return FileUtil.createDir(CitraApplication.getAppContext(), directory, directoryName) != null; - } - - public static int openContentUri(String path, String openMode) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.openContentUri(path, openMode); - } - return FileUtil.openContentUri(CitraApplication.getAppContext(), path, openMode); - } - - public static String[] getFilesName(String path) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.getFilesName(path); - } - return FileUtil.getFilesName(CitraApplication.getAppContext(), path); - } - - public static long getSize(String path) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.getFileSize(path); - } - return FileUtil.getFileSize(CitraApplication.getAppContext(), path); - } - - public static boolean fileExists(String path) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.Exists(path); - } - return FileUtil.Exists(CitraApplication.getAppContext(), path); - } - - public static boolean isDirectory(String path) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.isDirectory(path); - } - return FileUtil.isDirectory(CitraApplication.getAppContext(), path); - } - - public static boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) { - if (FileUtil.isNativePath(sourcePath) && FileUtil.isNativePath(destinationParentPath)) { - return CitraApplication.documentsTree.copyFile(sourcePath, destinationParentPath, destinationFilename); - } - return FileUtil.copyFile(CitraApplication.getAppContext(), sourcePath, destinationParentPath, destinationFilename); - } - - public static boolean renameFile(String path, String destinationFilename) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.renameFile(path, destinationFilename); - } - return FileUtil.renameFile(CitraApplication.getAppContext(), path, destinationFilename); - } - - public static boolean deleteDocument(String path) { - if (FileUtil.isNativePath(path)) { - return CitraApplication.documentsTree.deleteDocument(path); - } - return FileUtil.deleteDocument(CitraApplication.getAppContext(), path); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt new file mode 100644 index 000000000..ebcfa1933 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.kt @@ -0,0 +1,728 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu + +import android.Manifest.permission +import android.app.Dialog +import android.content.DialogInterface +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.Uri +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.core.content.ContextCompat +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.utils.EmulationMenuSettings +import org.citra.citra_emu.utils.FileUtil +import org.citra.citra_emu.utils.Log +import java.lang.ref.WeakReference +import java.util.Date + +/** + * Class which contains methods that interact + * with the native side of the Citra code. + */ +object NativeLibrary { + /** + * Default touchscreen device + */ + const val TouchScreenDevice = "Touchscreen" + + @JvmField + var sEmulationActivity = WeakReference(null) + private var alertResult = false + val alertLock = Object() + + init { + try { + System.loadLibrary("citra-android") + } catch (ex: UnsatisfiedLinkError) { + Log.error("[NativeLibrary] $ex") + } + } + + /** + * 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 onGamePadEvent(device: String, button: Int, action: Int): Boolean + + /** + * Handles gamepad movement events. + * + * @param device The device ID of the gamepad. + * @param axis The axis ID + * @param xAxis The value of the x-axis represented by the given ID. + * @param yAxis The value of the y-axis represented by the given ID + */ + external fun onGamePadMoveEvent(device: String, axis: Int, xAxis: Float, yAxis: Float): Boolean + + /** + * Handles gamepad movement events. + * + * @param device The device ID of the gamepad. + * @param axisId The axis ID + * @param axisVal The value of the axis represented by the given ID. + */ + external fun onGamePadAxisEvent(device: String?, axisId: Int, axisVal: Float): Boolean + + /** + * Handles touch events. + * + * @param xAxis The value of the x-axis. + * @param yAxis The value of the y-axis + * @param pressed To identify if the touch held down or released. + * @return true if the pointer is within the touchscreen + */ + external fun onTouchEvent(xAxis: Float, yAxis: Float, pressed: Boolean): Boolean + + /** + * Handles touch movement. + * + * @param xAxis The value of the instantaneous x-axis. + * @param yAxis The value of the instantaneous y-axis. + */ + external fun onTouchMoved(xAxis: Float, yAxis: Float) + + external fun reloadSettings() + + external fun getTitleId(filename: String): Long + + external fun getIsSystemTitle(path: String): Boolean + + /** + * Sets the current working user directory + * If not set, it auto-detects a location + */ + external fun setUserDirectory(directory: String) + external fun getInstalledGamePaths(): Array + + // Create the config.ini file. + external fun createConfigFile() + external fun createLogFile() + external fun logUserDirectory(directory: String) + + /** + * Begins emulation. + */ + external fun run(path: String) + + // Surface Handling + external fun surfaceChanged(surf: Surface) + external fun surfaceDestroyed() + external fun doFrame() + + /** + * Unpauses emulation from a paused state. + */ + external fun unPauseEmulation() + + /** + * Pauses emulation. + */ + external fun pauseEmulation() + + /** + * Stops emulation. + */ + external fun stopEmulation() + + /** + * Returns true if emulation is running (or is paused). + */ + external fun isRunning(): Boolean + + /** + * Returns the title ID of the currently running title, or 0 on failure. + */ + external fun getRunningTitleId(): Long + + /** + * Returns the performance stats for the current game + */ + external fun getPerfStats(): DoubleArray + + /** + * Notifies the core emulation that the orientation has changed. + */ + external fun notifyOrientationChange(layoutOption: Int, rotation: Int) + + /** + * Swaps the top and bottom screens. + */ + external fun swapScreens(swapScreens: Boolean, rotation: Int) + + external fun initializeGpuDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String? + ) + + external fun areKeysAvailable(): Boolean + + external fun getHomeMenuPath(region: Int): String + + external fun getSystemTitleIds(systemType: Int, region: Int): LongArray + + external fun downloadTitleFromNus(title: Long): InstallStatus + + private var coreErrorAlertResult = false + private val coreErrorAlertLock = Object() + + private fun onCoreErrorImpl(title: String, message: String) { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present") + return + } + val fragment = CoreErrorDialogFragment.newInstance(title, message) + fragment.show(emulationActivity.supportFragmentManager, CoreErrorDialogFragment.TAG) + } + + /** + * Handles a core error. + * @return true: continue; false: abort + */ + @Keep + @JvmStatic + fun onCoreError(error: CoreError?, details: String): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.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) { + try { + coreErrorAlertLock.wait() + } catch (ignored: Exception) { + } + } + return coreErrorAlertResult + } + + @get:Keep + @get:JvmStatic + val isPortraitMode: Boolean + get() = CitraApplication.appContext.resources.configuration.orientation == + Configuration.ORIENTATION_PORTRAIT + + @Keep + @JvmStatic + fun landscapeScreenLayout(): Int = EmulationMenuSettings.getLandscapeScreenLayout() + + @Keep + @JvmStatic + fun displayAlertMsg(title: String, message: String, yesNo: Boolean): Boolean { + Log.error("[NativeLibrary] Alert: $message") + val emulationActivity = sEmulationActivity.get() + var result = false + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert.") + } else { + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread { + AlertMessageDialogFragment.newInstance(title, message, yesNo).showNow( + emulationActivity.supportFragmentManager, + AlertMessageDialogFragment.TAG + ) + } + + // Wait for the lock to notify that it is complete. + synchronized(alertLock) { + try { + alertLock.wait() + } catch (_: Exception) { + } + } + if (yesNo) result = alertResult + } + return result + } + + class AlertMessageDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + // Create object used for waiting. + val builder = MaterialAlertDialogBuilder(requireContext()) + .setTitle(requireArguments().getString(TITLE)) + .setMessage(requireArguments().getString(MESSAGE)) + + // If not yes/no dialog just have one button that dismisses modal, + // otherwise have a yes and no button that sets alertResult accordingly. + if (!requireArguments().getBoolean(YES_NO)) { + builder + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + synchronized(alertLock) { alertLock.notify() } + } + } else { + alertResult = false + builder + .setPositiveButton(android.R.string.yes) { _: DialogInterface, _: Int -> + alertResult = true + synchronized(alertLock) { alertLock.notify() } + } + .setNegativeButton(android.R.string.no) { _: DialogInterface, _: Int -> + alertResult = false + synchronized(alertLock) { alertLock.notify() } + } + } + + return builder.show() + } + + companion object { + const val TAG = "AlertMessageDialogFragment" + + const val TITLE = "title" + const val MESSAGE = "message" + const val YES_NO = "yesNo" + + fun newInstance( + title: String, + message: String, + yesNo: Boolean + ): AlertMessageDialogFragment { + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + args.putBoolean(YES_NO, yesNo) + val fragment = AlertMessageDialogFragment() + fragment.arguments = args + return fragment + } + } + } + + @Keep + @JvmStatic + fun exitEmulationActivity(resultCode: Int) { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.") + return + } + + emulationActivity.runOnUiThread { + EmulationErrorDialogFragment.newInstance(resultCode).showNow( + emulationActivity.supportFragmentManager, + EmulationErrorDialogFragment.TAG + ) + } + } + + class EmulationErrorDialogFragment : DialogFragment() { + private lateinit var emulationActivity: EmulationActivity + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + emulationActivity = requireActivity() as EmulationActivity + + var captionId = R.string.loader_error_invalid_format + if (requireArguments().getInt(RESULT_CODE) == ErrorLoader_ErrorEncrypted) { + captionId = R.string.loader_error_encrypted + } + + val alert = MaterialAlertDialogBuilder(requireContext()) + .setTitle(captionId) + .setMessage( + Html.fromHtml( + CitraApplication.appContext.resources.getString(R.string.redump_games), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + emulationActivity.finish() + } + .create() + alert.show() + + val alertMessage = alert.findViewById(android.R.id.message) as TextView + alertMessage.movementMethod = LinkMovementMethod.getInstance() + + isCancelable = false + return alert + } + + companion object { + const val TAG = "EmulationErrorDialogFragment" + + const val RESULT_CODE = "resultcode" + + const val Success = 0 + const val ErrorNotInitialized = 1 + const val ErrorGetLoader = 2 + const val ErrorSystemMode = 3 + const val ErrorLoader = 4 + const val ErrorLoader_ErrorEncrypted = 5 + const val ErrorLoader_ErrorInvalidFormat = 6 + const val ErrorSystemFiles = 7 + const val ShutdownRequested = 11 + const val ErrorUnknown = 12 + + fun newInstance(resultCode: Int): EmulationErrorDialogFragment { + val args = Bundle() + args.putInt(RESULT_CODE, resultCode) + val fragment = EmulationErrorDialogFragment() + fragment.arguments = args + return fragment + } + } + } + + fun setEmulationActivity(emulationActivity: EmulationActivity?) { + Log.verbose("[NativeLibrary] Registering EmulationActivity.") + sEmulationActivity = WeakReference(emulationActivity) + } + + fun clearEmulationActivity() { + Log.verbose("[NativeLibrary] Unregistering EmulationActivity.") + sEmulationActivity.clear() + } + + private val cameraPermissionLock = Object() + private var cameraPermissionGranted = false + const val REQUEST_CODE_NATIVE_CAMERA = 800 + + @Keep + @JvmStatic + fun requestCameraPermission(): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present") + return false + } + if (ContextCompat.checkSelfPermission(emulationActivity, permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + // Permission already granted + return true + } + emulationActivity.requestPermissions(arrayOf(permission.CAMERA), REQUEST_CODE_NATIVE_CAMERA) + + // Wait until result is returned + synchronized(cameraPermissionLock) { + try { + cameraPermissionLock.wait() + } catch (ignored: InterruptedException) { + } + } + return cameraPermissionGranted + } + + fun cameraPermissionResult(granted: Boolean) { + cameraPermissionGranted = granted + synchronized(cameraPermissionLock) { cameraPermissionLock.notify() } + } + + private val micPermissionLock = Object() + private var micPermissionGranted = false + const val REQUEST_CODE_NATIVE_MIC = 900 + + @Keep + @JvmStatic + fun requestMicPermission(): Boolean { + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present") + return false + } + if (ContextCompat.checkSelfPermission(emulationActivity, permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + // Permission already granted + return true + } + emulationActivity.requestPermissions( + arrayOf(permission.RECORD_AUDIO), + REQUEST_CODE_NATIVE_MIC + ) + + // Wait until result is returned + synchronized(micPermissionLock) { + try { + micPermissionLock.wait() + } catch (ignored: InterruptedException) { + } + } + return micPermissionGranted + } + + fun micPermissionResult(granted: Boolean) { + micPermissionGranted = granted + synchronized(micPermissionLock) { micPermissionLock.notify() } + } + + // Notifies that the activity is now in foreground and camera devices can now be reloaded + external fun reloadCameraDevices() + + external fun loadAmiibo(path: String?): Boolean + + external fun removeAmiibo() + + const val SAVESTATE_SLOT_COUNT = 10 + + external fun getSavestateInfo(): Array? + + external fun saveState(slot: Int) + + external fun loadState(slot: Int) + + /** + * Logs the Citra version, Android version and, CPU. + */ + external fun logDeviceInfo() + + external fun loadSystemConfig() + + external fun saveSystemConfig() + + external fun setSystemSetupNeeded(needed: Boolean) + + external fun getIsSystemSetupNeeded(): Boolean + + @Keep + @JvmStatic + fun createFile(directory: String, filename: String): Boolean = + if (FileUtil.isNativePath(directory)) { + CitraApplication.documentsTree.createFile(directory, filename) + } else { + FileUtil.createFile(directory, filename) != null + } + + @Keep + @JvmStatic + fun createDir(directory: String, directoryName: String): Boolean = + if (FileUtil.isNativePath(directory)) { + CitraApplication.documentsTree.createDir(directory, directoryName) + } else { + FileUtil.createDir(directory, directoryName) != null + } + + @Keep + @JvmStatic + fun openContentUri(path: String, openMode: String): Int = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.openContentUri(path, openMode) + } else { + FileUtil.openContentUri(path, openMode) + } + + @Keep + @JvmStatic + fun getFilesName(path: String): Array = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.getFilesName(path) + } else { + FileUtil.getFilesName(path) + } + + @Keep + @JvmStatic + fun getSize(path: String): Long = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.getFileSize(path) + } else { + FileUtil.getFileSize(path) + } + + @Keep + @JvmStatic + fun fileExists(path: String): Boolean = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.exists(path) + } else { + FileUtil.exists(path) + } + + @Keep + @JvmStatic + fun isDirectory(path: String): Boolean = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.isDirectory(path) + } else { + FileUtil.isDirectory(path) + } + + @Keep + @JvmStatic + fun copyFile( + sourcePath: String, + destinationParentPath: String, + destinationFilename: String + ): Boolean = + if (FileUtil.isNativePath(sourcePath) && + FileUtil.isNativePath(destinationParentPath) + ) { + CitraApplication.documentsTree + .copyFile(sourcePath, destinationParentPath, destinationFilename) + } else { + FileUtil.copyFile( + Uri.parse(sourcePath), + Uri.parse(destinationParentPath), + destinationFilename + ) + } + + @Keep + @JvmStatic + fun renameFile(path: String, destinationFilename: String): Boolean = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.renameFile(path, destinationFilename) + } else { + FileUtil.renameFile(path, destinationFilename) + } + + @Keep + @JvmStatic + fun deleteDocument(path: String): Boolean = + if (FileUtil.isNativePath(path)) { + CitraApplication.documentsTree.deleteDocument(path) + } else { + FileUtil.deleteDocument(path) + } + + enum class CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown + } + + enum class InstallStatus { + Success, + ErrorFailedToOpenFile, + ErrorFileNotFound, + ErrorAborted, + ErrorInvalid, + ErrorEncrypted, + Cancelled + } + + class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val title = requireArguments().getString(TITLE) + val message = requireArguments().getString(MESSAGE) + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button) { _: DialogInterface?, _: Int -> + coreErrorAlertResult = true + } + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + coreErrorAlertResult = false + }.show() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + coreErrorAlertResult = true + synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } + } + + companion object { + const val TAG = "CoreErrorDialogFragment" + + const val TITLE = "title" + const val MESSAGE = "message" + + fun newInstance(title: String, message: String): CoreErrorDialogFragment { + val frag = CoreErrorDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } + } + + @Keep + class SaveStateInfo { + var slot = 0 + var time: Date? = null + } + + /** + * Button type for use in onTouchEvent + */ + object ButtonType { + const val BUTTON_A = 700 + const val BUTTON_B = 701 + const val BUTTON_X = 702 + const val BUTTON_Y = 703 + const val BUTTON_START = 704 + const val BUTTON_SELECT = 705 + const val BUTTON_HOME = 706 + const val BUTTON_ZL = 707 + const val BUTTON_ZR = 708 + const val DPAD_UP = 709 + const val DPAD_DOWN = 710 + const val DPAD_LEFT = 711 + const val DPAD_RIGHT = 712 + const val STICK_LEFT = 713 + const val STICK_LEFT_UP = 714 + const val STICK_LEFT_DOWN = 715 + const val STICK_LEFT_LEFT = 716 + const val STICK_LEFT_RIGHT = 717 + const val STICK_C = 718 + const val STICK_C_UP = 719 + const val STICK_C_DOWN = 720 + const val STICK_C_LEFT = 771 + const val STICK_C_RIGHT = 772 + const val TRIGGER_L = 773 + const val TRIGGER_R = 774 + const val DPAD = 780 + const val BUTTON_DEBUG = 781 + const val BUTTON_GPIO14 = 782 + } + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java index 2e2d0d112..6a6075782 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java @@ -18,6 +18,7 @@ import android.view.MenuItem; import android.view.MotionEvent; import android.view.SubMenu; import android.view.View; +import android.view.WindowManager; import android.widget.CheckBox; import android.widget.TextView; import android.widget.Toast; @@ -48,6 +49,7 @@ import org.citra.citra_emu.utils.EmulationMenuSettings; import org.citra.citra_emu.utils.FileBrowserHelper; import org.citra.citra_emu.utils.FileUtil; import org.citra.citra_emu.utils.ForegroundService; +import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.ThemeUtil; import java.io.File; @@ -169,8 +171,8 @@ public final class EmulationActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { - ThemeUtil.applyTheme(this); - + Log.gameLaunched = true; + ThemeUtil.INSTANCE.setTheme(this); super.onCreate(savedInstanceState); if (savedInstanceState == null) { @@ -210,7 +212,7 @@ public final class EmulationActivity extends AppCompatActivity { startForegroundService(foregroundService); // Override Citra core INI with the one set by our in game menu - NativeLibrary.SwapScreens(EmulationMenuSettings.getSwapScreens(), + NativeLibrary.INSTANCE.swapScreens(EmulationMenuSettings.getSwapScreens(), getWindowManager().getDefaultDisplay().getRotation()); } @@ -224,15 +226,12 @@ public final class EmulationActivity extends AppCompatActivity { protected void restoreState(Bundle savedInstanceState) { mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); - - // If an alert prompt was in progress when state was restored, retry displaying it - NativeLibrary.retryDisplayAlertPrompt(); } @Override public void onRestart() { super.onRestart(); - NativeLibrary.ReloadCameraDevices(); + NativeLibrary.INSTANCE.reloadCameraDevices(); } @Override @@ -257,7 +256,7 @@ public final class EmulationActivity extends AppCompatActivity { .setPositiveButton(android.R.string.ok, null) .show(); } - NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + NativeLibrary.INSTANCE.cameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); break; case NativeLibrary.REQUEST_CODE_NATIVE_MIC: if (grantResults[0] != PackageManager.PERMISSION_GRANTED && @@ -268,7 +267,7 @@ public final class EmulationActivity extends AppCompatActivity { .setPositiveButton(android.R.string.ok, null) .show(); } - NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + NativeLibrary.INSTANCE.micPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); break; default: super.onRequestPermissionsResult(requestCode, permissions, grantResults); @@ -281,6 +280,10 @@ public final class EmulationActivity extends AppCompatActivity { } private void enableFullscreenImmersive() { + // TODO: Remove this once we properly account for display insets in the input overlay + getWindow().getAttributes().layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER; + getWindow().getDecorView().setSystemUiVisibility( View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | @@ -323,7 +326,7 @@ public final class EmulationActivity extends AppCompatActivity { } private void DisplaySavestateWarning() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); if (preferences.getBoolean("savestateWarningShown", false)) { return; } @@ -350,7 +353,7 @@ public final class EmulationActivity extends AppCompatActivity { } private void updateSavestateMenuOptions(Menu menu) { - final NativeLibrary.SavestateInfo[] savestates = NativeLibrary.GetSavestateInfo(); + final NativeLibrary.SaveStateInfo[] savestates = NativeLibrary.INSTANCE.getSavestateInfo(); if (savestates == null) { menu.findItem(R.id.menu_emulation_save_state).setVisible(false); menu.findItem(R.id.menu_emulation_load_state).setVisible(false); @@ -370,18 +373,18 @@ public final class EmulationActivity extends AppCompatActivity { final String text = getString(R.string.emulation_empty_state_slot, slot); saveStateMenu.add(text).setEnabled(true).setOnMenuItemClickListener((item) -> { DisplaySavestateWarning(); - NativeLibrary.SaveState(slot); + NativeLibrary.INSTANCE.saveState(slot); return true; }); loadStateMenu.add(text).setEnabled(false).setOnMenuItemClickListener((item) -> { - NativeLibrary.LoadState(slot); + NativeLibrary.INSTANCE.loadState(slot); return true; }); } - for (final NativeLibrary.SavestateInfo info : savestates) { - final String text = getString(R.string.emulation_occupied_state_slot, info.slot, info.time); - saveStateMenu.getItem(info.slot - 1).setTitle(text); - loadStateMenu.getItem(info.slot - 1).setTitle(text).setEnabled(true); + for (final NativeLibrary.SaveStateInfo info : savestates) { + final String text = getString(R.string.emulation_occupied_state_slot, info.getSlot(), info.getTime()); + saveStateMenu.getItem(info.getSlot() - 1).setTitle(text); + loadStateMenu.getItem(info.getSlot() - 1).setTitle(text).setEnabled(true); } } @@ -441,7 +444,7 @@ public final class EmulationActivity extends AppCompatActivity { EmulationMenuSettings.setSwapScreens(isEnabled); item.setChecked(isEnabled); - NativeLibrary.SwapScreens(isEnabled, getWindowManager().getDefaultDisplay() + NativeLibrary.INSTANCE.swapScreens(isEnabled, getWindowManager().getDefaultDisplay() .getRotation()); break; } @@ -491,11 +494,11 @@ public final class EmulationActivity extends AppCompatActivity { break; case MENU_ACTION_OPEN_CHEATS: - CheatsActivity.launch(this, NativeLibrary.GetRunningTitleId()); + CheatsActivity.launch(this, NativeLibrary.INSTANCE.getRunningTitleId()); break; case MENU_ACTION_CLOSE_GAME: - NativeLibrary.PauseEmulation(); + NativeLibrary.INSTANCE.pauseEmulation(); new MaterialAlertDialogBuilder(this) .setTitle(R.string.emulation_close_game) .setMessage(R.string.emulation_close_game_message) @@ -504,8 +507,8 @@ public final class EmulationActivity extends AppCompatActivity { mEmulationFragment.stopEmulation(); finish(); }) - .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.UnPauseEmulation()) - .setOnCancelListener(dialogInterface -> NativeLibrary.UnPauseEmulation()) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> NativeLibrary.INSTANCE.unPauseEmulation()) + .setOnCancelListener(dialogInterface -> NativeLibrary.INSTANCE.unPauseEmulation()) .show(); break; } @@ -515,7 +518,7 @@ public final class EmulationActivity extends AppCompatActivity { private void changeScreenOrientation(int layoutOption, MenuItem item) { item.setChecked(true); - NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() + NativeLibrary.INSTANCE.notifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() .getRotation()); EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); } @@ -558,7 +561,7 @@ public final class EmulationActivity extends AppCompatActivity { return false; } - return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action); + return NativeLibrary.INSTANCE.onGamePadEvent(input.getDescriptor(), button, action); } @Override @@ -570,7 +573,7 @@ public final class EmulationActivity extends AppCompatActivity { } private void onAmiiboSelected(String selectedFile) { - boolean success = NativeLibrary.LoadAmiibo(selectedFile); + boolean success = NativeLibrary.INSTANCE.loadAmiibo(selectedFile); if (!success) { new MaterialAlertDialogBuilder(this) @@ -582,7 +585,7 @@ public final class EmulationActivity extends AppCompatActivity { } private void RemoveAmiibo() { - NativeLibrary.RemoveAmiibo(); + NativeLibrary.INSTANCE.removeAmiibo(); } private void toggleControls() { @@ -725,47 +728,47 @@ public final class EmulationActivity extends AppCompatActivity { } // Circle-Pad and C-Stick status - NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); - NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); + NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); + NativeLibrary.INSTANCE.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); // Triggers L/R and ZL/ZR if (isTriggerPressedLMapped) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); } if (isTriggerPressedRMapped) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); } if (isTriggerPressedZLMapped) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); } if (isTriggerPressedZRMapped) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); } // Work-around to allow D-pad axis to be bound to emulated buttons if (axisValuesDPad[0] == 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); } if (axisValuesDPad[0] < 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); } if (axisValuesDPad[0] > 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); } if (axisValuesDPad[1] == 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); } if (axisValuesDPad[1] < 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); } if (axisValuesDPad[1] > 0.f) { - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); } return true; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt new file mode 100644 index 000000000..835c01524 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/DriverAdapter.kt @@ -0,0 +1,119 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.adapters + +import android.net.Uri +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.CardDriverOptionBinding +import org.citra.citra_emu.utils.GpuDriverMetadata +import org.citra.citra_emu.viewmodel.DriverViewModel +import org.citra.citra_emu.utils.GpuDriverHelper + +class DriverAdapter(private val driverViewModel: DriverViewModel) : + ListAdapter, DriverAdapter.DriverViewHolder>( + AsyncDifferConfig.Builder(DiffCallback()).build() + ) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder { + val binding = + CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return DriverViewHolder(binding) + } + + override fun getItemCount(): Int = currentList.size + + override fun onBindViewHolder(holder: DriverViewHolder, position: Int) = + holder.bind(currentList[position]) + + private fun onSelectDriver(position: Int) { + driverViewModel.setSelectedDriverIndex(position) + notifyItemChanged(driverViewModel.previouslySelectedDriver) + notifyItemChanged(driverViewModel.selectedDriver) + } + + private fun onDeleteDriver(driverData: Pair, position: Int) { + if (driverViewModel.selectedDriver > position) { + driverViewModel.setSelectedDriverIndex(driverViewModel.selectedDriver - 1) + } + if (GpuDriverHelper.customDriverData == driverData.second) { + driverViewModel.setSelectedDriverIndex(0) + } + driverViewModel.driversToDelete.add(driverData.first) + driverViewModel.removeDriver(driverData) + notifyItemRemoved(position) + notifyItemChanged(driverViewModel.selectedDriver) + } + + inner class DriverViewHolder(val binding: CardDriverOptionBinding) : + RecyclerView.ViewHolder(binding.root) { + private lateinit var driverData: Pair + + fun bind(driverData: Pair) { + this.driverData = driverData + val driver = driverData.second + + binding.apply { + radioButton.isChecked = driverViewModel.selectedDriver == bindingAdapterPosition + root.setOnClickListener { + onSelectDriver(bindingAdapterPosition) + } + buttonDelete.setOnClickListener { + onDeleteDriver(driverData, bindingAdapterPosition) + } + + // Delay marquee by 3s + title.postDelayed( + { + title.isSelected = true + title.ellipsize = TextUtils.TruncateAt.MARQUEE + version.isSelected = true + version.ellipsize = TextUtils.TruncateAt.MARQUEE + description.isSelected = true + description.ellipsize = TextUtils.TruncateAt.MARQUEE + }, + 3000 + ) + if (driver.name == null) { + title.setText(R.string.system_gpu_driver) + description.text = "" + version.text = "" + version.visibility = View.GONE + description.visibility = View.GONE + buttonDelete.visibility = View.GONE + } else { + title.text = driver.name + version.text = driver.version + description.text = driver.description + version.visibility = View.VISIBLE + description.visibility = View.VISIBLE + buttonDelete.visibility = View.VISIBLE + } + } + } + } + + private class DiffCallback : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: Pair, + newItem: Pair + ): Boolean { + return oldItem.first == newItem.first + } + + override fun areContentsTheSame( + oldItem: Pair, + newItem: Pair + ): Boolean { + return oldItem.second == newItem.second + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java deleted file mode 100644 index 1c3cad9b1..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java +++ /dev/null @@ -1,261 +0,0 @@ -package org.citra.citra_emu.adapters; - -import android.content.Context; -import android.database.Cursor; -import android.database.DataSetObserver; -import android.os.Build; -import android.os.SystemClock; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.fragment.app.FragmentActivity; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.features.cheats.ui.CheatsActivity; -import org.citra.citra_emu.model.GameDatabase; -import org.citra.citra_emu.utils.FileUtil; -import org.citra.citra_emu.utils.Log; -import org.citra.citra_emu.utils.PicassoUtils; -import org.citra.citra_emu.viewholders.GameViewHolder; - -import java.util.stream.Stream; - -/** - * This adapter gets its information from a database Cursor. This fact, paired with the usage of - * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly) - * large dataset. - */ -public final class GameAdapter extends RecyclerView.Adapter { - private Cursor mCursor; - private GameDataSetObserver mObserver; - - private boolean mDatasetValid; - private long mLastClickTime = 0; - - /** - * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will - * display no data until a Cursor is supplied by a CursorLoader. - */ - public GameAdapter() { - mDatasetValid = false; - mObserver = new GameDataSetObserver(); - } - - /** - * Called by the LayoutManager when it is necessary to create a new view. - * - * @param parent The RecyclerView (I think?) the created view will be thrown into. - * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView. - * @return The created ViewHolder with references to all the child view's members. - */ - @Override - public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - // Create a new view. - View gameCard = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.card_game, parent, false); - - gameCard.setOnClickListener(this::onClick); - gameCard.setOnLongClickListener(this::onLongClick); - - // Use that view to create a ViewHolder. - return new GameViewHolder(gameCard); - } - - /** - * Called by the LayoutManager when a new view is not necessary because we can recycle - * an existing one (for example, if a view just scrolled onto the screen from the bottom, we - * can use the view that just scrolled off the top instead of inflating a new one.) - * - * @param holder A ViewHolder representing the view we're recycling. - * @param position The position of the 'new' view in the dataset. - */ - @RequiresApi(api = Build.VERSION_CODES.O) - @Override - public void onBindViewHolder(@NonNull GameViewHolder holder, int position) { - if (mDatasetValid) { - if (mCursor.moveToPosition(position)) { - PicassoUtils.loadGameIcon(holder.imageIcon, - mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); - - holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); - holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); - - String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); - String filename; - if (FileUtil.isNativePath(filepath)) { - filename = CitraApplication.documentsTree.getFilename(filepath); - } else { - filename = FileUtil.getFilename(CitraApplication.getAppContext(), filepath); - } - holder.textFileName.setText(filename); - - // TODO These shouldn't be necessary once the move to a DB-based model is complete. - holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); - holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); - holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE); - holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION); - holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS); - holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY); - - final int backgroundColorId = isValidGame(holder.path) ? R.attr.colorSurface : R.attr.colorErrorContainer; - View itemView = holder.getItemView(); - itemView.setBackgroundColor(MaterialColors.getColor(itemView, backgroundColorId)); - } else { - Log.error("[GameAdapter] Can't bind view; Cursor is not valid."); - } - } else { - Log.error("[GameAdapter] Can't bind view; dataset is not valid."); - } - } - - /** - * Called by the LayoutManager to find out how much data we have. - * - * @return Size of the dataset. - */ - @Override - public int getItemCount() { - if (mDatasetValid && mCursor != null) { - return mCursor.getCount(); - } - Log.error("[GameAdapter] Dataset is not valid."); - return 0; - } - - /** - * Return the contents of the _id column for a given row. - * - * @param position The row for which Android wants an ID. - * @return A valid ID from the database, or 0 if not available. - */ - @Override - public long getItemId(int position) { - if (mDatasetValid && mCursor != null) { - if (mCursor.moveToPosition(position)) { - return mCursor.getLong(GameDatabase.COLUMN_DB_ID); - } - } - - Log.error("[GameAdapter] Dataset is not valid."); - return 0; - } - - /** - * Tell Android whether or not each item in the dataset has a stable identifier. - * Which it does, because it's a database, so always tell Android 'true'. - * - * @param hasStableIds ignored. - */ - @Override - public void setHasStableIds(boolean hasStableIds) { - super.setHasStableIds(true); - } - - /** - * When a load is finished, call this to replace the existing data with the newly-loaded - * data. - * - * @param cursor The newly-loaded Cursor. - */ - public void swapCursor(Cursor cursor) { - // Sanity check. - if (cursor == mCursor) { - return; - } - - // Before getting rid of the old cursor, disassociate it from the Observer. - final Cursor oldCursor = mCursor; - if (oldCursor != null && mObserver != null) { - oldCursor.unregisterDataSetObserver(mObserver); - } - - mCursor = cursor; - if (mCursor != null) { - // Attempt to associate the new Cursor with the Observer. - if (mObserver != null) { - mCursor.registerDataSetObserver(mObserver); - } - - mDatasetValid = true; - } else { - mDatasetValid = false; - } - - notifyDataSetChanged(); - } - - /** - * Launches the game that was clicked on. - * - * @param view The view representing the game the user wants to play. - */ - private void onClick(View view) { - // Double-click prevention, using threshold of 1000 ms - if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) { - return; - } - mLastClickTime = SystemClock.elapsedRealtime(); - - GameViewHolder holder = (GameViewHolder) view.getTag(); - - EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title); - } - - /** - * Opens the cheats settings for the game that was clicked on. - * - * @param view The view representing the game the user wants to play. - */ - private boolean onLongClick(View view) { - Context context = view.getContext(); - GameViewHolder holder = (GameViewHolder) view.getTag(); - - final long titleId = NativeLibrary.GetTitleId(holder.path); - - if (titleId == 0) { - new MaterialAlertDialogBuilder(context) - .setIcon(R.mipmap.ic_launcher) - .setTitle(R.string.properties) - .setMessage(R.string.properties_not_loaded) - .setPositiveButton(android.R.string.ok, null) - .show(); - } else { - CheatsActivity.launch(context, titleId); - } - - return true; - } - - private boolean isValidGame(String path) { - return Stream.of( - ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix)); - } - - private final class GameDataSetObserver extends DataSetObserver { - @Override - public void onChanged() { - super.onChanged(); - - mDatasetValid = true; - notifyDataSetChanged(); - } - - @Override - public void onInvalidated() { - super.onInvalidated(); - - mDatasetValid = false; - notifyDataSetChanged(); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt new file mode 100644 index 000000000..b507ea5bd --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.kt @@ -0,0 +1,203 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.adapters + +import android.net.Uri +import android.os.SystemClock +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.preference.PreferenceManager +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.adapters.GameAdapter.GameViewHolder +import org.citra.citra_emu.databinding.CardGameBinding +import org.citra.citra_emu.features.cheats.ui.CheatsActivity +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.utils.GameIconUtils +import org.citra.citra_emu.viewmodel.GamesViewModel + +class GameAdapter(private val activity: AppCompatActivity) : + ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()), + View.OnClickListener, View.OnLongClickListener { + private var lastClickTime = 0L + + 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) + binding.cardGame.setOnLongClickListener(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) { + // Double-click prevention, using threshold of 1000 ms + if (SystemClock.elapsedRealtime() - lastClickTime < 1000) { + return + } + lastClickTime = SystemClock.elapsedRealtime() + + val holder = view.tag as GameViewHolder + gameExists(holder) + + val preferences = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + preferences.edit() + .putLong( + holder.game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + EmulationActivity.launch(activity, holder.game.path, holder.game.title) + } + + /** + * Opens the cheats settings for the game that was clicked on. + * + * @param view The view representing the game the user wants to play. + */ + override fun onLongClick(view: View): Boolean { + val context = view.context + val holder = view.tag as GameViewHolder + gameExists(holder) + + if (holder.game.titleId == 0L) { + MaterialAlertDialogBuilder(context) + .setTitle(R.string.properties) + .setMessage(R.string.properties_not_loaded) + .setPositiveButton(android.R.string.ok, null) + .show() + } else { + CheatsActivity.launch(view.context, holder.game.titleId) + } + return true + } + + // Triggers a library refresh if the user clicks on stale data + private fun gameExists(holder: GameViewHolder): Boolean { + if (holder.game.isInstalled) { + return true + } + + val gameExists = DocumentFile.fromSingleUri( + CitraApplication.appContext, + Uri.parse(holder.game.path) + )?.exists() == true + return if (!gameExists) { + Toast.makeText( + CitraApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + false + } else { + true + } + } + + 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 + GameIconUtils.loadGameIcon(activity, game, binding.imageGameScreen) + + binding.textGameTitle.visibility = if (game.title.isEmpty()) { + View.GONE + } else { + View.VISIBLE + } + binding.textCompany.visibility = if (game.company.isEmpty()) { + View.GONE + } else { + View.VISIBLE + } + + binding.textGameTitle.text = game.title + binding.textCompany.text = game.company + binding.textFilename.text = game.filename + + val backgroundColorId = + if ( + isValidGame(game.filename.substring(game.filename.lastIndexOf(".") + 1).lowercase()) + ) { + R.attr.colorSurface + } else { + R.attr.colorErrorContainer + } + binding.cardContents.setBackgroundColor( + MaterialColors.getColor( + binding.cardContents, + backgroundColorId + ) + ) + + binding.textGameTitle.postDelayed( + { + binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.textGameTitle.isSelected = true + + binding.textCompany.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.textCompany.isSelected = true + + binding.textFilename.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.textFilename.isSelected = true + }, + 3000 + ) + } + } + + private fun isValidGame(extension: String): Boolean { + return Game.badExtensions.stream() + .noneMatch { extension == it.lowercase() } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem.titleId == newItem.titleId + } + + override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt new file mode 100644 index 000000000..f90c65e77 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/HomeSettingAdapter.kt @@ -0,0 +1,112 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.adapters + +import android.text.TextUtils +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.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.CardHomeOptionBinding +import org.citra.citra_emu.fragments.MessageDialogFragment +import org.citra.citra_emu.model.HomeSetting +import org.citra.citra_emu.viewmodel.GamesViewModel + +class HomeSettingAdapter( + private val activity: AppCompatActivity, + private val viewLifecycle: LifecycleOwner, + 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 + if (holder.option.isEnabled.invoke()) { + holder.option.onClick.invoke() + } else { + MessageDialogFragment.newInstance( + holder.option.disabledTitleId, + holder.option.disabledMessageId + ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) + } + } + + 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 + ) + ) + + viewLifecycle.lifecycleScope.launch { + viewLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { + option.details.collect { updateOptionDetails(it) } + } + } + binding.optionDetail.postDelayed( + { + binding.optionDetail.ellipsize = TextUtils.TruncateAt.MARQUEE + binding.optionDetail.isSelected = true + }, + 3000 + ) + + if (option.isEnabled.invoke()) { + binding.optionTitle.alpha = 1f + binding.optionDescription.alpha = 1f + binding.optionIcon.alpha = 1f + } else { + binding.optionTitle.alpha = 0.5f + binding.optionDescription.alpha = 0.5f + binding.optionIcon.alpha = 0.5f + } + } + + private fun updateOptionDetails(detailString: String) { + if (detailString != "") { + binding.optionDetail.text = detailString + binding.optionDetail.visibility = View.VISIBLE + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt new file mode 100644 index 000000000..dd3f4debe --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/LicenseAdapter.kt @@ -0,0 +1,55 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.adapters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.databinding.ListItemSettingBinding +import org.citra.citra_emu.fragments.LicenseBottomSheetDialogFragment +import org.citra.citra_emu.model.License + +class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List) : + RecyclerView.Adapter(), + View.OnClickListener { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { + val binding = + ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.root.setOnClickListener(this) + return LicenseViewHolder(binding) + } + + override fun getItemCount(): Int = licenses.size + + override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) { + holder.bind(licenses[position]) + } + + override fun onClick(view: View) { + val license = (view.tag as LicenseViewHolder).license + LicenseBottomSheetDialogFragment.newInstance(license) + .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) + } + + inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) { + lateinit var license: License + + init { + itemView.tag = this + } + + fun bind(license: License) { + this.license = license + + val context = CitraApplication.appContext + binding.textSettingName.text = context.getString(license.titleId) + binding.textSettingDescription.text = context.getString(license.descriptionId) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt new file mode 100644 index 000000000..b6917b072 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/SetupAdapter.kt @@ -0,0 +1,87 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.adapters + +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +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.citra.citra_emu.databinding.PageSetupBinding +import org.citra.citra_emu.model.SetupCallback +import org.citra.citra_emu.model.SetupPage +import org.citra.citra_emu.model.StepState +import org.citra.citra_emu.utils.ViewUtils + +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), SetupCallback { + lateinit var page: SetupPage + + init { + itemView.tag = this + } + + fun bind(page: SetupPage) { + this.page = page + + if (page.stepCompleted.invoke() == StepState.STEP_COMPLETE) { + onStepCompleted() + } + + 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.textDescription.movementMethod = LinkMovementMethod.getInstance() + + 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(this@SetupPageViewHolder) + } + } + } + + override fun onStepCompleted() { + ViewUtils.hideView(binding.buttonAction, 200) + ViewUtils.showView(binding.textConfirmation, 200) + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java index d6fce6a30..67f51bc6d 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java @@ -18,13 +18,16 @@ import java.util.Arrays; import java.util.Collections; import java.util.Objects; +import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import com.google.android.material.dialog.MaterialAlertDialogBuilder; +@Keep public final class MiiSelector { + @Keep public static class MiiSelectorConfig implements java.io.Serializable { public boolean enable_cancel_button; public String title; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java index 800ecaf86..77b02a6f0 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java @@ -7,13 +7,17 @@ package org.citra.citra_emu.applets; import android.app.Activity; import android.app.Dialog; import android.content.DialogInterface; +import android.content.res.Resources; import android.os.Bundle; import android.text.InputFilter; import android.text.Spanned; +import android.util.TypedValue; import android.view.ViewGroup; import android.widget.EditText; import android.widget.FrameLayout; +import androidx.annotation.ColorInt; +import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; @@ -29,6 +33,7 @@ import org.citra.citra_emu.utils.Log; import java.util.Objects; +@Keep public final class SoftwareKeyboard { /// Corresponds to Frontend::ButtonConfig private interface ButtonConfig { @@ -57,6 +62,7 @@ public final class SoftwareKeyboard { EmptyInputNotAllowed, } + @Keep public static class KeyboardConfig implements java.io.Serializable { public int button_config; public int max_text_length; @@ -109,20 +115,27 @@ public final class SoftwareKeyboard { FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); params.leftMargin = params.rightMargin = - CitraApplication.getAppContext().getResources().getDimensionPixelSize( + CitraApplication.Companion.getAppContext().getResources().getDimensionPixelSize( R.dimen.dialog_margin); KeyboardConfig config = Objects.requireNonNull( (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config")); // Set up the input - EditText editText = new EditText(CitraApplication.getAppContext()); + EditText editText = new EditText(CitraApplication.Companion.getAppContext()); editText.setHint(config.hint_text); editText.setSingleLine(!config.multiline_mode); editText.setLayoutParams(params); editText.setFilters(new InputFilter[]{ new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); + TypedValue typedValue = new TypedValue(); + Resources.Theme theme = requireContext().getTheme(); + theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true); + @ColorInt int color = typedValue.data; + editText.setHintTextColor(color); + editText.setTextColor(color); + FrameLayout container = new FrameLayout(emulationActivity); container.addView(editText); @@ -256,7 +269,7 @@ public final class SoftwareKeyboard { public static void ShowError(String error) { NativeLibrary.displayAlertMsg( - CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard), + CitraApplication.Companion.getAppContext().getResources().getString(R.string.software_keyboard), error, false); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java index 701cb0710..55be2660a 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java @@ -13,6 +13,7 @@ import org.citra.citra_emu.R; import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.utils.PicassoUtils; +import androidx.annotation.Keep; import androidx.annotation.Nullable; // Used in native code. @@ -23,6 +24,7 @@ public final class StillImageCameraHelper { String filePickerPath; // Opens file picker for camera. + @Keep public static @Nullable String OpenFilePicker() { final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); @@ -58,6 +60,7 @@ public final class StillImageCameraHelper { } // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Keep @Nullable public static Bitmap LoadImageFromFile(String uri, int width, int height) { return PicassoUtils.LoadBitmapFromFile(uri, width, height); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java deleted file mode 100644 index 7d70e94b4..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CitraDirectoryDialog.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.citra.citra_emu.dialogs; - -import android.app.Dialog; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Bundle; -import android.preference.PreferenceManager; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.CheckBox; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentActivity; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.util.Objects; -import org.citra.citra_emu.R; -import org.citra.citra_emu.utils.FileUtil; -import org.citra.citra_emu.utils.PermissionsHandler; - -public class CitraDirectoryDialog extends DialogFragment { - public static final String TAG = "citra_directory_dialog_fragment"; - - private static final String MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE"; - - TextView pathView; - - TextView spaceView; - - CheckBox checkBox; - - AlertDialog dialog; - - Listener listener; - - public interface Listener { - void onPressPositiveButton(boolean moveData, Uri path); - } - - public static CitraDirectoryDialog newInstance(String path, Listener listener) { - CitraDirectoryDialog frag = new CitraDirectoryDialog(); - frag.listener = listener; - Bundle args = new Bundle(); - args.putString("path", path); - frag.setArguments(args); - return frag; - } - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final FragmentActivity activity = requireActivity(); - final Uri path = Uri.parse(Objects.requireNonNull(requireArguments().getString("path"))); - SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(activity); - String freeSpaceText = - getResources().getString(R.string.free_space, FileUtil.getFreeSpace(activity, path)); - - LayoutInflater inflater = getLayoutInflater(); - View view = inflater.inflate(R.layout.dialog_citra_directory, null); - - checkBox = view.findViewById(R.id.checkBox); - pathView = view.findViewById(R.id.path); - spaceView = view.findViewById(R.id.space); - - checkBox.setChecked(mPreferences.getBoolean(MOVE_DATE_ENABLE, true)); - if (!PermissionsHandler.hasWriteAccess(activity)) { - checkBox.setVisibility(View.GONE); - } - checkBox.setOnCheckedChangeListener( - (v, isChecked) - // record move data selection with SharedPreferences - -> mPreferences.edit().putBoolean(MOVE_DATE_ENABLE, checkBox.isChecked()).apply()); - - pathView.setText(path.getPath()); - spaceView.setText(freeSpaceText); - - setCancelable(false); - - dialog = new MaterialAlertDialogBuilder(activity) - .setView(view) - .setIcon(R.mipmap.ic_launcher) - .setTitle(R.string.app_name) - .setPositiveButton( - android.R.string.ok, - (d, v) -> listener.onPressPositiveButton(checkBox.isChecked(), path)) - .setNegativeButton(android.R.string.cancel, null) - .create(); - return dialog; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java deleted file mode 100644 index f13e626ee..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/CopyDirProgressDialog.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.citra.citra_emu.dialogs; - -import android.app.Dialog; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.ProgressBar; -import android.widget.TextView; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.FragmentActivity; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.citra.citra_emu.R; - -public class CopyDirProgressDialog extends DialogFragment { - public static final String TAG = "copy_dir_progress_dialog"; - ProgressBar progressBar; - - TextView progressText; - - AlertDialog dialog; - - @NonNull - @Override - public Dialog onCreateDialog(Bundle savedInstanceState) { - final FragmentActivity activity = requireActivity(); - - LayoutInflater inflater = getLayoutInflater(); - View view = inflater.inflate(R.layout.dialog_progress_bar, null); - - progressBar = view.findViewById(R.id.progress_bar); - progressText = view.findViewById(R.id.progress_text); - progressText.setText(""); - - setCancelable(false); - - dialog = new MaterialAlertDialogBuilder(activity) - .setView(view) - .setIcon(R.mipmap.ic_launcher) - .setTitle(R.string.move_data) - .setMessage("") - .create(); - return dialog; - } - - public void onUpdateSearchProgress(String msg) { - requireActivity().runOnUiThread(() -> { - dialog.setMessage(getResources().getString(R.string.searching_direcotry, msg)); - }); - } - - public void onUpdateCopyProgress(String msg, int progress, int max) { - requireActivity().runOnUiThread(() -> { - progressBar.setProgress(progress); - progressBar.setMax(max); - progressText.setText(String.format("%d/%d", progress, max)); - dialog.setMessage(getResources().getString(R.string.copy_file_name, msg)); - }); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java index 45d0daf5b..9446d1ad9 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java @@ -51,8 +51,7 @@ public class CheatsActivity extends AppCompatActivity @Override protected void onCreate(Bundle savedInstanceState) { - ThemeUtil.applyTheme(this); - + ThemeUtil.INSTANCE.setTheme(this); super.onCreate(savedInstanceState); WindowCompat.setDecorFitsSystemWindows(getWindow(), false); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java index 9684966f2..997dd1e26 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java @@ -14,7 +14,12 @@ import java.util.Map; import java.util.TreeMap; public class Settings { - public static final String SECTION_PREMIUM = "Premium"; + public static final String PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"; + public static final String PREF_MATERIAL_YOU = "MaterialYouTheme"; + public static final String PREF_THEME_MODE = "ThemeMode"; + public static final String PREF_BLACK_BACKGROUNDS = "BlackBackgrounds"; + public static final String PREF_SHOW_HOME_APPS = "ShowHomeApps"; + public static final String SECTION_CORE = "Core"; public static final String SECTION_SYSTEM = "System"; public static final String SECTION_CAMERA = "Camera"; @@ -30,7 +35,7 @@ public class Settings { private static final Map> configFileSectionsMap = new HashMap<>(); static { - configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG)); + configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG)); } /** @@ -109,7 +114,7 @@ public class Settings { public void saveSettings(SettingsActivityView view) { if (TextUtils.isEmpty(gameId)) { - view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false); + view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.ini_saved), false); for (Map.Entry> entry : configFileSectionsMap.entrySet()) { String fileName = entry.getKey(); @@ -121,12 +126,6 @@ public class Settings { SettingsFile.saveFile(fileName, iniSections, view); } - } else { - // custom game settings - view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false); - - SettingsFile.saveCustomGameSettings(gameId, sections); } - } -} \ No newline at end of file +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java index baf40709f..6bafecfe0 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java @@ -59,7 +59,7 @@ public final class CheckBoxSetting extends SettingsItem { public IntSetting setChecked(boolean checked) { // Show a performance warning if the setting has been disabled if (mShowPerformanceWarning && !checked) { - mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true); + mView.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.performance_warning), true); } if (getSetting() == null) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java index e9141a208..6d4d954e8 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java @@ -201,7 +201,7 @@ public final class InputBindingSetting extends SettingsItem { */ public void removeOldMapping() { // Get preferences editor - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); SharedPreferences.Editor editor = preferences.edit(); // Try remove all possible keys we wrote for this setting @@ -250,7 +250,7 @@ public final class InputBindingSetting extends SettingsItem { */ private void WriteButtonMapping(String key) { // Get preferences editor - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); SharedPreferences.Editor editor = preferences.edit(); // Remove mapping for another setting using this input @@ -278,7 +278,7 @@ public final class InputBindingSetting extends SettingsItem { */ private void WriteAxisMapping(int axis, int value) { // Get preferences editor - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); SharedPreferences.Editor editor = preferences.edit(); // Cleanup old mapping @@ -302,7 +302,7 @@ public final class InputBindingSetting extends SettingsItem { */ public void onKeyInput(KeyEvent keyEvent) { if (!IsButtonMappingSupported()) { - Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show(); + Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show(); return; } @@ -324,11 +324,11 @@ public final class InputBindingSetting extends SettingsItem { public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange, char axisDir) { if (!IsAxisMappingSupported()) { - Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show(); + Toast.makeText(CitraApplication.Companion.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show(); return; } - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); SharedPreferences.Editor editor = preferences.edit(); int button; @@ -354,7 +354,7 @@ public final class InputBindingSetting extends SettingsItem { * Sets the string to use in the configuration UI for the gamepad input. */ private StringSetting setUiString(String ui) { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); SharedPreferences.Editor editor = preferences.edit(); if (getSetting() == null) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java deleted file mode 100644 index 8942bf13a..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.citra.citra_emu.features.settings.model.view; - -import org.citra.citra_emu.features.settings.model.Setting; - -public final class PremiumHeader extends SettingsItem { - public PremiumHeader() { - super(null, null, null, 0, 0); - } - - @Override - public int getType() { - return SettingsItem.TYPE_PREMIUM; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java deleted file mode 100644 index c0560d2dc..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.citra.citra_emu.features.settings.model.view; - -import android.content.SharedPreferences; -import android.preference.PreferenceManager; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.model.Setting; -import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; - -public final class PremiumSingleChoiceSetting extends SettingsItem { - private int mDefaultValue; - - private int mChoicesId; - private int mValuesId; - private SettingsFragmentView mView; - - private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); - - public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId, - int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) { - super(key, section, setting, titleId, descriptionId); - mValuesId = valuesId; - mChoicesId = choicesId; - mDefaultValue = defaultValue; - mView = view; - } - - public int getChoicesId() { - return mChoicesId; - } - - public int getValuesId() { - return mValuesId; - } - - public int getSelectedValue() { - return mPreferences.getInt(getKey(), mDefaultValue); - } - - /** - * Write a value to the backing int. If that int was previously null, - * initializes a new one and returns it, so it can be added to the Hashmap. - * - * @param selection New value of the int. - * @return null if overwritten successfully otherwise; a newly created IntSetting. - */ - public void setSelectedValue(int selection) { - final SharedPreferences.Editor editor = mPreferences.edit(); - editor.putInt(getKey(), selection); - editor.apply(); - mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false); - } - - @Override - public int getType() { - return TYPE_SINGLE_CHOICE; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java index 305352022..8a5642696 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java @@ -20,7 +20,6 @@ public abstract class SettingsItem { public static final int TYPE_INPUT_BINDING = 5; public static final int TYPE_STRING_SINGLE_CHOICE = 6; public static final int TYPE_DATETIME_SETTING = 7; - public static final int TYPE_PREMIUM = 8; private String mKey; private String mSection; @@ -29,7 +28,6 @@ public abstract class SettingsItem { private int mNameId; private int mDescriptionId; - private boolean mIsPremium; /** * Base constructor. Takes a key / section name in case the third parameter, the Setting, @@ -48,7 +46,6 @@ public abstract class SettingsItem { mSetting = setting; mNameId = nameId; mDescriptionId = descriptionId; - mIsPremium = (section == Settings.SECTION_PREMIUM); } /** @@ -93,10 +90,6 @@ public abstract class SettingsItem { return mDescriptionId; } - public boolean isPremium() { - return mIsPremium; - } - /** * Used by {@link SettingsAdapter}'s onCreateViewHolder() * method to determine which type of ViewHolder should be created. diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java index 19aacb7f5..58ffbbfea 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java @@ -26,7 +26,6 @@ import com.google.android.material.appbar.MaterialToolbar; import org.citra.citra_emu.NativeLibrary; import org.citra.citra_emu.R; import org.citra.citra_emu.utils.DirectoryInitialization; -import org.citra.citra_emu.utils.DirectoryStateReceiver; import org.citra.citra_emu.utils.EmulationMenuSettings; import org.citra.citra_emu.utils.InsetsHelper; import org.citra.citra_emu.utils.ThemeUtil; @@ -48,8 +47,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting @Override protected void onCreate(Bundle savedInstanceState) { - ThemeUtil.applyTheme(this); - + ThemeUtil.INSTANCE.setTheme(this); super.onCreate(savedInstanceState); setContentView(R.layout.activity_settings); @@ -109,7 +107,7 @@ public final class SettingsActivity extends AppCompatActivity implements Setting mPresenter.onStop(isFinishing()); // Update framebuffer layout when closing the settings - NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), + NativeLibrary.INSTANCE.notifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), getWindowManager().getDefaultDisplay().getRotation()); } @@ -147,19 +145,6 @@ public final class SettingsActivity extends AppCompatActivity implements Setting return duration != 0 && transition != 0; } - @Override - public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) { - LocalBroadcastManager.getInstance(this).registerReceiver( - receiver, - filter); - DirectoryInitialization.start(this); - } - - @Override - public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) { - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); - } - @Override public void showLoading() { if (dialog == null) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java index b4f7c22d1..84a7d9d64 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java @@ -11,7 +11,6 @@ import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.utils.SettingsFile; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; -import org.citra.citra_emu.utils.DirectoryStateReceiver; import org.citra.citra_emu.utils.Log; import org.citra.citra_emu.utils.ThemeUtil; @@ -24,8 +23,6 @@ public final class SettingsActivityPresenter { private boolean mShouldSave; - private DirectoryStateReceiver directoryStateReceiver; - private String menuTag; private String gameId; @@ -64,30 +61,7 @@ public final class SettingsActivityPresenter { if (configFile == null || !configFile.exists()) { Log.error("Citra config file could not be found!"); } - if (DirectoryInitialization.areCitraDirectoriesReady()) { - loadSettingsUI(); - } else { - mView.showLoading(); - IntentFilter statusIntentFilter = new IntentFilter( - DirectoryInitialization.BROADCAST_ACTION); - - directoryStateReceiver = - new DirectoryStateReceiver(directoryInitializationState -> - { - if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { - mView.hideLoading(); - loadSettingsUI(); - } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { - mView.showPermissionNeededHint(); - mView.hideLoading(); - } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { - mView.showExternalStorageNotMountedHint(); - mView.hideLoading(); - } - }); - - mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter); - } + loadSettingsUI(); } public void setSettings(Settings settings) { @@ -99,17 +73,12 @@ public final class SettingsActivityPresenter { } public void onStop(boolean finishing) { - if (directoryStateReceiver != null) { - mView.stopListeningToDirectoryInitializationService(directoryStateReceiver); - directoryStateReceiver = null; - } - if (mSettings != null && finishing && mShouldSave) { Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI..."); mSettings.saveSettings(mView); } - NativeLibrary.ReloadSettings(); + NativeLibrary.INSTANCE.reloadSettings(); } public void onSettingChanged() { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java index 0d26d48a7..bd2f5f5aa 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java @@ -3,7 +3,6 @@ package org.citra.citra_emu.features.settings.ui; import android.content.IntentFilter; import org.citra.citra_emu.features.settings.model.Settings; -import org.citra.citra_emu.utils.DirectoryStateReceiver; /** * Abstraction for the Activity that manages SettingsFragments. @@ -85,19 +84,4 @@ public interface SettingsActivityView { * Show a hint to the user that the app needs the external storage to be mounted */ void showExternalStorageNotMountedHint(); - - /** - * Start the DirectoryInitialization and listen for the result. - * - * @param receiver the broadcast receiver for the DirectoryInitialization - * @param filter the Intent broadcasts to be received. - */ - void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter); - - /** - * Stop listening to the DirectoryInitialization. - * - * @param receiver The broadcast receiver to unregister. - */ - void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java index 59c37394e..b03ec11b4 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java @@ -24,7 +24,6 @@ import org.citra.citra_emu.features.settings.model.StringSetting; import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; -import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SettingsItem; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SliderSetting; @@ -34,12 +33,10 @@ import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHo import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder; -import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; -import org.citra.citra_emu.ui.main.MainActivity; import org.citra.citra_emu.utils.Log; import java.util.ArrayList; @@ -97,10 +94,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter onSingleChoiceClick(item)); - } - - public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) { - mClickedPosition = position; - - if (!item.isPremium() || MainActivity.isPremiumActive()) { - // Setting is either not Premium, or the user has Premium - onSingleChoiceClick(item); - return; - } - - // User needs Premium, invoke the billing flow - MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + onSingleChoiceClick(item); } public void onStringSingleChoiceClick(StringSingleChoiceSetting item) { @@ -205,15 +166,7 @@ public final class SettingsAdapter extends RecyclerView.Adapter onStringSingleChoiceClick(item)); + onStringSingleChoiceClick(item); } DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog(); @@ -351,10 +304,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter 0) { - int[] valuesArray = mContext.getResources().getIntArray(valuesId); - return valuesArray[which]; - } else { - return which; - } - } - private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) { int value = item.getSelectedValue(); int valuesId = item.getValuesId(); @@ -447,25 +385,6 @@ public final class SettingsAdapter extends RecyclerView.Adapter 0) { - int[] valuesArray = mContext.getResources().getIntArray(valuesId); - for (int index = 0; index < valuesArray.length; index++) { - int current = valuesArray[index]; - if (current == value) { - return index; - } - } - } else { - return value; - } - - return -1; - } - @Override public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) { mSliderProgress = (int) value; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java index 9f73e1ff2..3e53cf465 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java @@ -17,8 +17,6 @@ import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; import org.citra.citra_emu.features.settings.model.view.HeaderSetting; import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; -import org.citra.citra_emu.features.settings.model.view.PremiumHeader; -import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SettingsItem; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SliderSetting; @@ -107,9 +105,6 @@ public final class SettingsFragmentPresenter { case SettingsFile.FILE_NAME_CONFIG: addConfigSettings(sl); break; - case Settings.SECTION_PREMIUM: - addPremiumSettings(sl); - break; case Settings.SECTION_CORE: addGeneralSettings(sl); break; @@ -143,7 +138,6 @@ public final class SettingsFragmentPresenter { private void addConfigSettings(ArrayList sl) { mView.getActivity().setTitle(R.string.preferences_settings); - sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM)); sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE)); sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM)); sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA)); @@ -153,25 +147,6 @@ public final class SettingsFragmentPresenter { sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG)); } - private void addPremiumSettings(ArrayList sl) { - mView.getActivity().setTitle(R.string.preferences_premium); - - SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM); - Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN); - - sl.add(new PremiumHeader()); - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView)); - } else { - // Pre-Android 10 does not support System Default - sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView)); - } - - Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME); - sl.add(new SingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, 0, R.array.textureFilterNames, R.array.textureFilterValues, 0, textureFilterName)); - } - private void addGeneralSettings(ArrayList sl) { mView.getActivity().setTitle(R.string.preferences_general); @@ -367,6 +342,7 @@ public final class SettingsFragmentPresenter { Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D); Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D); Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE); + Setting textureFilterName = rendererSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME); SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT); Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE); Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT); @@ -385,6 +361,7 @@ public final class SettingsFragmentPresenter { sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode)); sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul)); sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_RENDERER, R.string.texture_filter_name, 0, R.array.textureFilterNames, R.array.textureFilterValues, 0, textureFilterName)); sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0)); sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode)); diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java deleted file mode 100644 index be0853ff0..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.citra.citra_emu.features.settings.ui.viewholder; - -import android.view.View; -import android.widget.TextView; - -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.model.view.SettingsItem; -import org.citra.citra_emu.features.settings.ui.SettingsAdapter; -import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; -import org.citra.citra_emu.ui.main.MainActivity; - -public final class PremiumViewHolder extends SettingViewHolder { - private TextView mHeaderName; - private TextView mTextDescription; - private SettingsFragmentView mView; - - public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) { - super(itemView, adapter); - mView = view; - itemView.setOnClickListener(this); - } - - @Override - protected void findViews(View root) { - mHeaderName = root.findViewById(R.id.text_setting_name); - mTextDescription = root.findViewById(R.id.text_setting_description); - } - - @Override - public void bind(SettingsItem item) { - updateText(); - } - - @Override - public void onClick(View clicked) { - if (MainActivity.isPremiumActive()) { - return; - } - - // Invoke billing flow if Premium is not already active, then refresh the UI to indicate - // the purchase has completed. - MainActivity.invokePremiumBilling(() -> updateText()); - } - - /** - * Update the text shown to the user, based on whether Premium is active - */ - private void updateText() { - if (MainActivity.isPremiumActive()) { - mHeaderName.setText(R.string.premium_settings_welcome); - mTextDescription.setText(R.string.premium_settings_welcome_description); - } else { - mHeaderName.setText(R.string.premium_settings_upsell); - mTextDescription.setText(R.string.premium_settings_upsell_description); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java index a175af9f8..f735b7752 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java @@ -5,7 +5,6 @@ import android.view.View; import android.widget.TextView; import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SettingsItem; import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; @@ -46,17 +45,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder { mTextSettingDescription.setText(choices[i]); } } - } else if (item instanceof PremiumSingleChoiceSetting) { - PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item; - int selected = setting.getSelectedValue(); - Resources resMgr = mTextSettingDescription.getContext().getResources(); - String[] choices = resMgr.getStringArray(setting.getChoicesId()); - int[] values = resMgr.getIntArray(setting.getValuesId()); - for (int i = 0; i < values.length; ++i) { - if (values[i] == selected) { - mTextSettingDescription.setText(choices[i]); - } - } } else { mTextSettingDescription.setVisibility(View.GONE); } @@ -67,8 +55,6 @@ public final class SingleChoiceViewHolder extends SettingViewHolder { int position = getAdapterPosition(); if (mItem instanceof SingleChoiceSetting) { getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position); - } else if (mItem instanceof PremiumSingleChoiceSetting) { - getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position); } else if (mItem instanceof StringSingleChoiceSetting) { getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java index fec8f3282..4590100cd 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java @@ -42,7 +42,6 @@ public final class SettingsFile { public static final String KEY_DESIGN = "design"; - public static final String KEY_PREMIUM = "premium"; public static final String KEY_GRAPHICS_API = "graphics_api"; public static final String KEY_SPIRV_SHADER_GEN = "spirv_shader_gen"; @@ -160,7 +159,7 @@ public final class SettingsFile { BufferedReader reader = null; try { - Context context = CitraApplication.getAppContext(); + Context context = CitraApplication.Companion.getAppContext(); InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); reader = new BufferedReader(new InputStreamReader(inputStream)); @@ -226,7 +225,7 @@ public final class SettingsFile { DocumentFile ini = getSettingsFile(fileName); try { - Context context = CitraApplication.getAppContext(); + Context context = CitraApplication.Companion.getAppContext(); InputStream inputStream = context.getContentResolver().openInputStream(ini.getUri()); Wini writer = new Wini(inputStream); @@ -242,24 +241,7 @@ public final class SettingsFile { outputStream.close(); } catch (IOException e) { Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); - view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); - } - } - - - public static void saveCustomGameSettings(final String gameId, final HashMap sections) { - Set sortedSections = new TreeSet<>(sections.keySet()); - - for (String sectionKey : sortedSections) { - SettingSection section = sections.get(sectionKey); - - HashMap settings = section.getSettings(); - Set sortedKeySet = new TreeSet<>(settings.keySet()); - - for (String settingKey : sortedKeySet) { - Setting setting = settings.get(settingKey); - NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString()); - } + view.showToastMessage(CitraApplication.Companion.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); } } @@ -280,13 +262,13 @@ public final class SettingsFile { } public static DocumentFile getSettingsFile(String fileName) { - DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory())); + DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory())); DocumentFile configDirectory = root.findFile("config"); return configDirectory.findFile(fileName + ".ini"); } private static DocumentFile getCustomGameSettingsFile(String gameId) { - DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.getAppContext(), Uri.parse(DirectoryInitialization.getUserDirectory())); + DocumentFile root = DocumentFile.fromTreeUri(CitraApplication.Companion.getAppContext(), Uri.parse(DirectoryInitialization.INSTANCE.getUserDirectory())); DocumentFile configDirectory = root.findFile("GameSettings"); return configDirectory.findFile(gameId + ".ini"); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt new file mode 100644 index 000000000..3943aa23d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/AboutFragment.kt @@ -0,0 +1,123 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.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.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import com.google.android.material.transition.MaterialSharedAxis +import org.citra.citra_emu.BuildConfig +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.FragmentAboutBinding +import org.citra.citra_emu.viewmodel.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) + reenterTransition = 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 { + binding.root.findNavController().popBackStack() + } + + binding.buttonContributors.setOnClickListener { + openLink( + getString(R.string.contributors_link) + ) + } + binding.buttonLicenses.setOnClickListener { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + } + + binding.textBuildHash.text = BuildConfig.VERSION_NAME + 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.toolbarAbout.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarAbout.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/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt new file mode 100644 index 000000000..aa2a0716f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CitraDirectoryDialogFragment.kt @@ -0,0 +1,92 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogCitraDirectoryBinding +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.utils.PermissionsHandler +import org.citra.citra_emu.viewmodel.HomeViewModel + +class CitraDirectoryDialogFragment : DialogFragment() { + private lateinit var binding: DialogCitraDirectoryBinding + + private val homeViewModel: HomeViewModel by activityViewModels() + + fun interface Listener { + fun onPressPositiveButton(moveData: Boolean, path: Uri) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogCitraDirectoryBinding.inflate(layoutInflater) + + val path = Uri.parse(requireArguments().getString(PATH)) + + binding.checkBox.isChecked = savedInstanceState?.getBoolean(MOVE_DATE_ENABLE) ?: false + val oldPath = PermissionsHandler.citraDirectory + if (!PermissionsHandler.hasWriteAccess(requireActivity()) || + oldPath.toString() == path.toString() + ) { + binding.checkBox.visibility = View.GONE + } + binding.path.text = path.path + binding.path.isSelected = true + + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.select_citra_user_folder) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + homeViewModel.directoryListener?.onPressPositiveButton( + if (binding.checkBox.visibility != View.GONE) { + binding.checkBox.isChecked + } else { + false + }, + path + ) + } + .setNegativeButton(android.R.string.cancel) { _: DialogInterface?, _: Int -> + if (!PermissionsHandler.hasWriteAccess(requireContext())) { + (requireActivity() as MainActivity).openCitraDirectory.launch(null) + } + } + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(MOVE_DATE_ENABLE, binding.checkBox.isChecked) + } + + companion object { + const val TAG = "citra_directory_dialog_fragment" + private const val MOVE_DATE_ENABLE = "IS_MODE_DATA_ENABLE" + private const val PATH = "path" + + fun newInstance( + activity: FragmentActivity, + path: String, + listener: Listener + ): CitraDirectoryDialogFragment { + val dialog = CitraDirectoryDialogFragment() + ViewModelProvider(activity)[HomeViewModel::class.java].directoryListener = listener + val args = Bundle() + args.putString(PATH, path) + dialog.arguments = args + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt new file mode 100644 index 000000000..7fd92f979 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CopyDirProgressDialogFragment.kt @@ -0,0 +1,153 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogCopyDirBinding +import org.citra.citra_emu.model.SetupCallback +import org.citra.citra_emu.utils.CitraDirectoryHelper +import org.citra.citra_emu.utils.FileUtil +import org.citra.citra_emu.utils.PermissionsHandler +import org.citra.citra_emu.viewmodel.HomeViewModel + +class CopyDirProgressDialog : DialogFragment() { + private var _binding: DialogCopyDirBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogCopyDirBinding.inflate(layoutInflater) + + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.moving_data) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.messageText.collectLatest { binding.messageText.text = it } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.dirProgress.collectLatest { + binding.progressBar.max = homeViewModel.maxDirProgress.value + binding.progressBar.progress = it + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.copyComplete.collect { + if (it) { + homeViewModel.setUserDir( + requireActivity(), + PermissionsHandler.citraDirectory.path!! + ) + homeViewModel.copyInProgress = false + homeViewModel.setPickingUserDir(false) + Toast.makeText( + requireContext(), + R.string.copy_complete, + Toast.LENGTH_SHORT + ).show() + dismiss() + } + } + } + } + } + } + + override fun onDestroy() { + super.onDestroy() + _binding = null + } + + companion object { + const val TAG = "CopyDirProgressDialog" + + fun newInstance( + activity: FragmentActivity, + previous: Uri, + path: Uri, + callback: SetupCallback? = null + ): CopyDirProgressDialog? { + val viewModel = ViewModelProvider(activity)[HomeViewModel::class.java] + if (viewModel.copyInProgress) { + return null + } + viewModel.clearCopyInfo() + viewModel.copyInProgress = true + + activity.lifecycleScope.launch { + withContext(Dispatchers.IO) { + FileUtil.copyDir( + previous.toString(), + path.toString(), + object : FileUtil.CopyDirListener { + override fun onSearchProgress(directoryName: String) { + viewModel.onUpdateSearchProgress( + CitraApplication.appContext.resources, + directoryName + ) + } + + override fun onCopyProgress(filename: String, progress: Int, max: Int) { + viewModel.onUpdateCopyProgress( + CitraApplication.appContext.resources, + filename, + progress, + max + ) + } + + override fun onComplete() { + CitraDirectoryHelper.initializeCitraDirectory(path) + callback?.onStepCompleted() + viewModel.setCopyComplete(true) + } + }) + } + } + return CopyDirProgressDialog() + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt new file mode 100644 index 000000000..3f5abfd14 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DownloadSystemFilesDialogFragment.kt @@ -0,0 +1,152 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +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.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.citra.citra_emu.NativeLibrary.InstallStatus +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogProgressBarBinding +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.SystemFilesViewModel + +class DownloadSystemFilesDialogFragment : DialogFragment() { + private var _binding: DialogProgressBarBinding? = null + private val binding get() = _binding!! + + private val downloadViewModel: SystemFilesViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + private lateinit var titles: LongArray + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + _binding = DialogProgressBarBinding.inflate(layoutInflater) + + titles = requireArguments().getLongArray(TITLES)!! + + binding.progressText.visibility = View.GONE + + binding.progressBar.min = 0 + binding.progressBar.max = titles.size + if (downloadViewModel.isDownloading.value != true) { + binding.progressBar.progress = 0 + } + + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.downloading_files) + .setMessage(R.string.downloading_files_description) + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + downloadViewModel.progress.collectLatest { binding.progressBar.progress = it } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + downloadViewModel.result.collect { + when (it) { + InstallStatus.Success -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance(R.string.download_success, 0) + .show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + gamesViewModel.setShouldSwapData(true) + } + + InstallStatus.ErrorFailedToOpenFile, + InstallStatus.ErrorEncrypted, + InstallStatus.ErrorFileNotFound, + InstallStatus.ErrorInvalid, + InstallStatus.ErrorAborted -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance( + R.string.download_failed, + R.string.download_failed_description + ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + gamesViewModel.setShouldSwapData(true) + } + + InstallStatus.Cancelled -> { + downloadViewModel.clear() + dismiss() + MessageDialogFragment.newInstance( + R.string.download_cancelled, + R.string.download_cancelled_description + ).show(requireActivity().supportFragmentManager, MessageDialogFragment.TAG) + } + + // Do nothing on null + else -> {} + } + } + } + } + } + + // Consider using WorkManager here. While the home menu can only really amount to + // about 150MBs, this could be a problem on inconsistent networks + downloadViewModel.download(titles) + } + + override fun onResume() { + super.onResume() + val alertDialog = dialog as AlertDialog + val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { + downloadViewModel.cancel() + dialog?.setTitle(R.string.cancelling) + binding.progressBar.isIndeterminate = true + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "DownloadSystemFilesDialogFragment" + + const val TITLES = "Titles" + + fun newInstance(titles: LongArray): DownloadSystemFilesDialogFragment { + val dialog = DownloadSystemFilesDialogFragment() + val args = Bundle() + args.putLongArray(TITLES, titles) + dialog.arguments = args + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt new file mode 100644 index 000000000..016ba34ae --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriverManagerFragment.kt @@ -0,0 +1,182 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.DriverAdapter +import org.citra.citra_emu.databinding.FragmentDriverManagerBinding +import org.citra.citra_emu.utils.FileUtil.asDocumentFile +import org.citra.citra_emu.utils.FileUtil.inputStream +import org.citra.citra_emu.utils.GpuDriverHelper +import org.citra.citra_emu.viewmodel.HomeViewModel +import org.citra.citra_emu.viewmodel.DriverViewModel +import java.io.IOException + +class DriverManagerFragment : Fragment() { + private var _binding: FragmentDriverManagerBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentDriverManagerBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + if (!driverViewModel.isInteractionAllowed) { + DriversLoadingDialogFragment().show( + childFragmentManager, + DriversLoadingDialogFragment.TAG + ) + } + + binding.toolbarDrivers.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.buttonInstall.setOnClickListener { + getDriver.launch(arrayOf("application/zip")) + } + + binding.listDrivers.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.game_grid_columns) + ) + adapter = DriverAdapter(driverViewModel) + } + + viewLifecycleOwner.lifecycleScope.apply { + launch { + driverViewModel.driverList.collectLatest { + (binding.listDrivers.adapter as DriverAdapter).submitList(it) + } + } + launch { + driverViewModel.newDriverInstalled.collect { + if (_binding != null && it) { + (binding.listDrivers.adapter as DriverAdapter).apply { + notifyItemChanged(driverViewModel.previouslySelectedDriver) + notifyItemChanged(driverViewModel.selectedDriver) + driverViewModel.setNewDriverInstalled(false) + } + } + } + } + } + + setInsets() + } + + // Start installing requested driver + override fun onStop() { + super.onStop() + driverViewModel.onCloseDriverManager() + } + + 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.toolbarDrivers.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarDrivers.layoutParams = mlpAppBar + + val mlplistDrivers = binding.listDrivers.layoutParams as ViewGroup.MarginLayoutParams + mlplistDrivers.leftMargin = leftInsets + mlplistDrivers.rightMargin = rightInsets + binding.listDrivers.layoutParams = mlplistDrivers + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + val mlpFab = + binding.buttonInstall.layoutParams as ViewGroup.MarginLayoutParams + mlpFab.leftMargin = leftInsets + fabSpacing + mlpFab.rightMargin = rightInsets + fabSpacing + mlpFab.bottomMargin = barInsets.bottom + fabSpacing + binding.buttonInstall.layoutParams = mlpFab + + binding.listDrivers.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val getDriver = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + IndeterminateProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_driver, + false + ) { + // Ignore file exceptions when a user selects an invalid zip + val driverFile: DocumentFile + try { + driverFile = GpuDriverHelper.copyDriverToExternalStorage(result) + ?: throw IOException("Driver failed validation!") + } catch (_: IOException) { + return@newInstance getString(R.string.select_gpu_driver_error) + } + + val driverData = GpuDriverHelper.getMetadataFromZip(driverFile.inputStream()) + val driverInList = + driverViewModel.driverList.value.firstOrNull { it.second == driverData } + if (driverInList != null) { + driverFile.delete() + return@newInstance getString(R.string.driver_already_installed) + } else { + driverViewModel.addDriver(Pair(driverFile.uri, driverData)) + driverViewModel.setNewDriverInstalled(true) + } + return@newInstance Any() + }.show(childFragmentManager, IndeterminateProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 000000000..8a6ff5c57 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/DriversLoadingDialogFragment.kt @@ -0,0 +1,76 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogProgressBarBinding +import org.citra.citra_emu.viewmodel.DriverViewModel + +class DriversLoadingDialogFragment : DialogFragment() { + private val driverViewModel: DriverViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + + isCancelable = false + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.loading) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.areDriversLoading.collect { checkForDismiss() } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.isDriverReady.collect { checkForDismiss() } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + driverViewModel.isDeletingDrivers.collect { checkForDismiss() } + } + } + } + } + + private fun checkForDismiss() { + if (driverViewModel.isInteractionAllowed) { + dismiss() + } + } + + companion object { + const val TAG = "DriversLoadingDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java index 445faa047..834bd3317 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java @@ -27,7 +27,6 @@ import org.citra.citra_emu.activities.EmulationActivity; import org.citra.citra_emu.overlay.InputOverlay; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; -import org.citra.citra_emu.utils.DirectoryStateReceiver; import org.citra.citra_emu.utils.EmulationMenuSettings; import org.citra.citra_emu.utils.Log; @@ -42,8 +41,6 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C private EmulationState mEmulationState; - private DirectoryStateReceiver directoryStateReceiver; - private EmulationActivity activity; private TextView mPerfStats; @@ -65,7 +62,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C if (context instanceof EmulationActivity) { activity = (EmulationActivity) context; - NativeLibrary.setEmulationActivity((EmulationActivity) context); + NativeLibrary.INSTANCE.setEmulationActivity((EmulationActivity) context); } else { throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); } @@ -116,20 +113,11 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C public void onResume() { super.onResume(); Choreographer.getInstance().postFrameCallback(this); - if (DirectoryInitialization.areCitraDirectoriesReady()) { - mEmulationState.run(activity.isActivityRecreated()); - } else { - setupCitraDirectoriesThenStartEmulation(); - } + mEmulationState.run(activity.isActivityRecreated()); } @Override public void onPause() { - if (directoryStateReceiver != null) { - LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver); - directoryStateReceiver = null; - } - if (mEmulationState.isRunning()) { mEmulationState.pause(); } @@ -140,39 +128,10 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C @Override public void onDetach() { - NativeLibrary.clearEmulationActivity(); + NativeLibrary.INSTANCE.clearEmulationActivity(); super.onDetach(); } - private void setupCitraDirectoriesThenStartEmulation() { - IntentFilter statusIntentFilter = new IntentFilter( - DirectoryInitialization.BROADCAST_ACTION); - - directoryStateReceiver = - new DirectoryStateReceiver(directoryInitializationState -> - { - if (directoryInitializationState == - DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { - mEmulationState.run(activity.isActivityRecreated()); - } else if (directoryInitializationState == - DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { - Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT) - .show(); - } else if (directoryInitializationState == - DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { - Toast.makeText(getContext(), R.string.external_storage_not_mounted, - Toast.LENGTH_SHORT) - .show(); - } - }); - - // Registers the DirectoryStateReceiver and its intent filters - LocalBroadcastManager.getInstance(getActivity()).registerReceiver( - directoryStateReceiver, - statusIntentFilter); - DirectoryInitialization.start(getActivity()); - } - public void refreshInputOverlay() { mInputOverlay.refreshControls(); } @@ -195,7 +154,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C perfStatsUpdater = () -> { - final double[] perfStats = NativeLibrary.GetPerfStats(); + final double[] perfStats = NativeLibrary.INSTANCE.getPerfStats(); if (perfStats[FPS] > 0) { mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS] + 0.5), (int) (perfStats[SPEED] * 100.0 + 0.5))); @@ -235,7 +194,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C @Override public void doFrame(long frameTimeNanos) { Choreographer.getInstance().postFrameCallback(this); - NativeLibrary.DoFrame(); + NativeLibrary.INSTANCE.doFrame(); } public void stopEmulation() { @@ -286,7 +245,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C if (state != State.STOPPED) { Log.debug("[EmulationFragment] Stopping emulation."); state = State.STOPPED; - NativeLibrary.StopEmulation(); + NativeLibrary.INSTANCE.stopEmulation(); } else { Log.warning("[EmulationFragment] Stop called while already stopped."); } @@ -300,8 +259,8 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C Log.debug("[EmulationFragment] Pausing emulation."); // Release the surface before pausing, since emulation has to be running for that. - NativeLibrary.SurfaceDestroyed(); - NativeLibrary.PauseEmulation(); + NativeLibrary.INSTANCE.surfaceDestroyed(); + NativeLibrary.INSTANCE.pauseEmulation(); } else { Log.warning("[EmulationFragment] Pause called while already paused."); } @@ -309,7 +268,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C public synchronized void run(boolean isActivityRecreated) { if (isActivityRecreated) { - if (NativeLibrary.IsRunning()) { + if (NativeLibrary.INSTANCE.isRunning()) { state = State.PAUSED; } } else { @@ -340,7 +299,7 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C Log.debug("[EmulationFragment] Surface destroyed."); if (state == State.RUNNING) { - NativeLibrary.SurfaceDestroyed(); + NativeLibrary.INSTANCE.surfaceDestroyed(); state = State.PAUSED; } else if (state == State.PAUSED) { Log.warning("[EmulationFragment] Surface cleared while emulation paused."); @@ -353,18 +312,18 @@ public final class EmulationFragment extends Fragment implements SurfaceHolder.C private void runWithValidSurface() { mRunWhenSurfaceIsValid = false; if (state == State.STOPPED) { - NativeLibrary.SurfaceChanged(mSurface); + NativeLibrary.INSTANCE.surfaceChanged(mSurface); Thread mEmulationThread = new Thread(() -> { Log.debug("[EmulationFragment] Starting emulation thread."); - NativeLibrary.Run(mGamePath); + NativeLibrary.INSTANCE.run(mGamePath); }, "NativeEmulation"); mEmulationThread.start(); } else if (state == State.PAUSED) { Log.debug("[EmulationFragment] Resuming emulation."); - NativeLibrary.SurfaceChanged(mSurface); - NativeLibrary.UnPauseEmulation(); + NativeLibrary.INSTANCE.surfaceChanged(mSurface); + NativeLibrary.INSTANCE.unPauseEmulation(); } else { Log.debug("[EmulationFragment] Bug, run called while already running."); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt new file mode 100644 index 000000000..4c93bef97 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/GamesFragment.kt @@ -0,0 +1,202 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.transition.MaterialFadeThrough +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.GameAdapter +import org.citra.citra_emu.databinding.FragmentGamesBinding +import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.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 + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + + binding.gridGames.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.game_grid_columns) + ) + 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 + } + } + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + gamesViewModel.isReloading.collect { isReloading -> + binding.swipeRefresh.isRefreshing = isReloading + if (gamesViewModel.games.value.isEmpty() && !isReloading) { + binding.noticeText.visibility = View.VISIBLE + } else { + binding.noticeText.visibility = View.INVISIBLE + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + gamesViewModel.games.collectLatest { setAdapter(it) } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + gamesViewModel.shouldSwapData.collect { + if (it) { + setAdapter(gamesViewModel.games.value) + gamesViewModel.setShouldSwapData(false) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + gamesViewModel.shouldScrollToTop.collect { + if (it) { + scrollToTop() + gamesViewModel.setShouldScrollToTop(false) + } + } + } + } + } + + setInsets() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun setAdapter(games: List) { + val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + if (preferences.getBoolean(Settings.PREF_SHOW_HOME_APPS, false)) { + (binding.gridGames.adapter as GameAdapter).submitList(games) + } else { + val filteredList = games.filter { !it.isSystemTitle } + (binding.gridGames.adapter as GameAdapter).submitList(filteredList) + } + } + + 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/citra/citra_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt new file mode 100644 index 000000000..05379d8d6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/HomeSettingsFragment.kt @@ -0,0 +1,252 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.content.Intent +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.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.HomeSettingAdapter +import org.citra.citra_emu.databinding.FragmentHomeSettingsBinding +import org.citra.citra_emu.features.settings.ui.SettingsActivity +import org.citra.citra_emu.features.settings.utils.SettingsFile +import org.citra.citra_emu.model.HomeSetting +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.utils.GameHelper +import org.citra.citra_emu.utils.PermissionsHandler +import org.citra.citra_emu.viewmodel.HomeViewModel +import org.citra.citra_emu.utils.GpuDriverHelper +import org.citra.citra_emu.utils.Log +import org.citra.citra_emu.viewmodel.DriverViewModel + +class HomeSettingsFragment : Fragment() { + private var _binding: FragmentHomeSettingsBinding? = null + private val binding get() = _binding!! + + private lateinit var mainActivity: MainActivity + + private val homeViewModel: HomeViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private val preferences get() = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + 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 = listOf( + HomeSetting( + R.string.grid_menu_core_settings, + R.string.settings_description, + R.drawable.ic_settings, + { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") } + ), + HomeSetting( + R.string.system_files, + R.string.system_files_description, + R.drawable.ic_system_update, + { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_systemFilesFragment) + } + ), + HomeSetting( + R.string.install_game_content, + R.string.install_game_content_description, + R.drawable.ic_install, + { mainActivity.ciaFileInstaller.launch(true) } + ), + HomeSetting( + R.string.share_log, + R.string.share_log_description, + R.drawable.ic_share, + { shareLog() } + ), + HomeSetting( + R.string.gpu_driver_manager, + R.string.install_gpu_driver_description, + R.drawable.ic_install_driver, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_driverManagerFragment) + }, + { GpuDriverHelper.supportsCustomDriverLoading() }, + R.string.custom_driver_not_supported, + R.string.custom_driver_not_supported_description, + driverViewModel.selectedDriverMetadata + ), + HomeSetting( + R.string.select_citra_user_folder, + R.string.select_citra_user_folder_home_description, + R.drawable.ic_home, + { mainActivity.openCitraDirectory.launch(null) }, + details = homeViewModel.userDir + ), + HomeSetting( + R.string.select_games_folder, + R.string.select_games_folder_description, + R.drawable.ic_add, + { getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, + details = homeViewModel.gamesDir + ), + 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) + } + ) + ) + + binding.homeSettingsList.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.game_grid_columns) + ) + adapter = HomeSettingAdapter( + requireActivity() as AppCompatActivity, + viewLifecycleOwner, + 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 val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + requireContext().contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + preferences.edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() + + Toast.makeText( + CitraApplication.appContext, + R.string.games_dir_selected, + Toast.LENGTH_LONG + ).show() + + homeViewModel.setGamesDir(requireActivity(), result.path!!) + } + + private fun shareLog() { + val logDirectory = DocumentFile.fromTreeUri( + requireContext(), + PermissionsHandler.citraDirectory + )?.findFile("log") + val currentLog = logDirectory?.findFile("citra_log.txt") + val oldLog = logDirectory?.findFile("citra_log.txt.old.txt") + + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + } + if (!Log.gameLaunched && oldLog?.exists() == true) { + intent.putExtra(Intent.EXTRA_STREAM, oldLog.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else if (currentLog?.exists() == true) { + intent.putExtra(Intent.EXTRA_STREAM, currentLog.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else { + Toast.makeText( + requireContext(), + getText(R.string.share_log_not_found), + Toast.LENGTH_SHORT + ).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/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt new file mode 100644 index 000000000..31b01e636 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/IndeterminateProgressDialogFragment.kt @@ -0,0 +1,137 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.databinding.DialogProgressBarBinding +import org.citra.citra_emu.viewmodel.TaskViewModel + +class IndeterminateProgressDialogFragment : DialogFragment() { + private val taskViewModel: TaskViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE) + val cancellable = requireArguments().getBoolean(CANCELLABLE) + + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(titleId) + .setView(binding.root) + + if (cancellable) { + dialog.setNegativeButton(android.R.string.cancel, null) + } + + val alertDialog = dialog.create() + alertDialog.setCanceledOnTouchOutside(false) + + if (!taskViewModel.isRunning.value) { + taskViewModel.runTask() + } + 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) + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.isComplete.collect { + if (it) { + dismiss() + when (val result = taskViewModel.result.value) { + is String -> Toast.makeText( + requireContext(), + result, + Toast.LENGTH_LONG + ).show() + + is MessageDialogFragment -> result.show( + requireActivity().supportFragmentManager, + MessageDialogFragment.TAG + ) + + else -> { + // Do nothing + } + } + taskViewModel.clear() + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + taskViewModel.cancelled.collect { + if (it) { + dialog?.setTitle(R.string.cancelling) + } + } + } + } + } + } + + // By default, the ProgressDialog will immediately dismiss itself upon a button being pressed. + // Setting the OnClickListener again after the dialog is shown overrides this behavior. + override fun onResume() { + super.onResume() + val alertDialog = dialog as AlertDialog + val negativeButton = alertDialog.getButton(Dialog.BUTTON_NEGATIVE) + negativeButton.setOnClickListener { + alertDialog.setTitle(getString(R.string.cancelling)) + taskViewModel.setCancelled(true) + } + } + + companion object { + const val TAG = "IndeterminateProgressDialogFragment" + + private const val TITLE = "Title" + private const val CANCELLABLE = "Cancellable" + + fun newInstance( + activity: FragmentActivity, + titleId: Int, + cancellable: Boolean = false, + task: () -> Any + ): IndeterminateProgressDialogFragment { + val dialog = IndeterminateProgressDialogFragment() + val args = Bundle() + ViewModelProvider(activity)[TaskViewModel::class.java].task = task + args.putInt(TITLE, titleId) + args.putBoolean(CANCELLABLE, cancellable) + dialog.arguments = args + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 000000000..9cbea1b9b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -0,0 +1,70 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import org.citra.citra_emu.databinding.DialogLicenseBinding +import org.citra.citra_emu.model.License +import org.citra.citra_emu.utils.SerializableHelper.parcelable + +class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogLicenseBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogLicenseBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable(LICENSE)!! + + binding.apply { + textTitle.setText(license.titleId) + textLink.setText(license.linkId) + if (license.copyrightId != 0) { + textCopyright.setText(license.copyrightId) + } else { + textCopyright.visibility = View.GONE + } + if (license.licenseId != 0) { + textLicense.setText(license.licenseId) + } else { + textLicense.setText(license.licenseLinkId) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_COLLAPSED + } + } + } + + companion object { + const val TAG = "LicenseBottomSheetDialogFragment" + + const val LICENSE = "License" + + fun newInstance( + license: License + ): LicenseBottomSheetDialogFragment { + val dialog = LicenseBottomSheetDialogFragment() + val bundle = Bundle() + bundle.putParcelable(LICENSE, license) + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt new file mode 100644 index 000000000..a8f767907 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/LicensesFragment.kt @@ -0,0 +1,201 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.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 androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.LicenseAdapter +import org.citra.citra_emu.databinding.FragmentLicensesBinding +import org.citra.citra_emu.model.License +import org.citra.citra_emu.viewmodel.HomeViewModel + +class LicensesFragment : Fragment() { + private var _binding: FragmentLicensesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLicensesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarLicenses.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val licenses = listOf( + License( + R.string.license_adreno_tools, + R.string.license_adreno_tools_description, + R.string.license_adreno_tools_link, + R.string.license_adreno_tools_copyright, + R.string.license_adreno_tools_text + ), + License( + R.string.license_cubeb, + R.string.license_cubeb_description, + R.string.license_cubeb_link, + R.string.license_cubeb_copyright, + R.string.license_cubeb_text + ), + License( + R.string.license_dynarmic, + R.string.license_dynarmic_description, + R.string.license_dynarmic_link, + R.string.license_dynarmic_copyright, + R.string.license_dynarmic_text + ), + License( + R.string.license_sirit, + R.string.license_sirit_description, + R.string.license_sirit_link, + R.string.license_sirit_copyright, + R.string.license_sirit_text + ), + License( + R.string.license_cryptopp, + R.string.license_cryptopp_description, + R.string.license_cryptopp_link, + R.string.license_cryptopp_copyright, + R.string.license_cryptopp_text + ), + License( + titleId = R.string.license_boost, + descriptionId = R.string.license_boost_description, + linkId = R.string.license_boost_link, + licenseId = R.string.license_boost_text + ), + License( + R.string.license_nihstro, + R.string.license_nihstro_description, + R.string.license_nihstro_link, + R.string.license_nihstro_copyright, + R.string.license_nihstro_text + ), + License( + R.string.license_httplib, + R.string.license_httplib_description, + R.string.license_httplib_link, + R.string.license_httplib_copyright, + R.string.license_mit + ), + License( + R.string.license_teakra, + R.string.license_teakra_description, + R.string.license_teakra_link, + R.string.license_teakra_copyright, + R.string.license_mit + ), + License( + R.string.license_enet, + R.string.license_enet_description, + R.string.license_enet_link, + R.string.license_enet_copyright, + R.string.license_mit + ), + License( + R.string.license_glad, + R.string.license_glad_description, + R.string.license_glad_link, + R.string.license_glad_copyright, + R.string.license_mit + ), + License( + titleId = R.string.license_glslang, + descriptionId = R.string.license_glslang_description, + linkId = R.string.license_glslang_link, + licenseLinkId = R.string.license_glslang_link_license + ), + License( + R.string.license_openal, + R.string.license_openal_description, + R.string.license_openal_link, + R.string.license_openal_copyright, + R.string.license_openal_text + ), + License( + R.string.license_sdl, + R.string.license_sdl_description, + R.string.license_sdl_link, + R.string.license_sdl_copyright, + R.string.license_sdl_text + ), + License( + R.string.license_vma, + R.string.license_vma_description, + R.string.license_vma_link, + R.string.license_vma_copyright, + R.string.license_mit + ), + License( + R.string.license_zstd, + R.string.license_zstd_description, + R.string.license_zstd_link, + R.string.license_zstd_copyright, + R.string.license_zstd_text + ) + ) + + binding.listLicenses.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + val mlpAppBar = binding.toolbarLicenses.layoutParams as MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarLicenses.layoutParams = mlpAppBar + + val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams + mlpScrollAbout.leftMargin = leftInsets + mlpScrollAbout.rightMargin = rightInsets + binding.listLicenses.layoutParams = mlpScrollAbout + + binding.listLicenses.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt new file mode 100644 index 000000000..93113a1ff --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/MessageDialogFragment.kt @@ -0,0 +1,86 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R + +class MessageDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE_ID) + val descriptionId = requireArguments().getInt(DESCRIPTION_ID) + val descriptionString = requireArguments().getString(DESCRIPTION_STRING) ?: "" + val helpLinkId = requireArguments().getInt(HELP_LINK) + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.close, null) + .setTitle(titleId) + + if (descriptionString.isNotEmpty()) { + dialog.setMessage(descriptionString) + } else if (descriptionId != 0) { + dialog.setMessage(descriptionId) + } + + if (helpLinkId != 0) { + dialog.setNeutralButton(R.string.learn_more) { _, _ -> + openLink(getString(helpLinkId)) + } + } + + return dialog.show() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + companion object { + const val TAG = "MessageDialogFragment" + + private const val TITLE_ID = "Title" + private const val DESCRIPTION_ID = "Description" + private const val DESCRIPTION_STRING = "Description_string" + private const val HELP_LINK = "Link" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int = 0 + ): MessageDialogFragment { + val dialog = MessageDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE_ID, titleId) + putInt(DESCRIPTION_ID, descriptionId) + putInt(HELP_LINK, helpLinkId) + } + dialog.arguments = bundle + return dialog + } + + fun newInstance( + titleId: Int, + description: String, + helpLinkId: Int = 0 + ): MessageDialogFragment { + val dialog = MessageDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE_ID, titleId) + putString(DESCRIPTION_STRING, description) + putInt(HELP_LINK, helpLinkId) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt new file mode 100644 index 000000000..a38dd1471 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SearchFragment.kt @@ -0,0 +1,260 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.annotation.SuppressLint +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.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler +import kotlinx.coroutines.launch +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.GameAdapter +import org.citra.citra_emu.databinding.FragmentSearchBinding +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.HomeViewModel +import java.time.temporal.ChronoField +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 + } + + // This is using the correct scope, lint is just acting up + @SuppressLint("UnsafeRepeatOnLifecycleDetector") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + + preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + binding.gridGamesSearch.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.game_grid_columns) + ) + 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() + } + + viewLifecycleOwner.lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + gamesViewModel.searchFocused.collect { + if (it) { + focusSearch() + gamesViewModel.setSearchFocused(false) + } + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + gamesViewModel.games.collect { filterAndSearch() } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + gamesViewModel.searchedGames.collect { + (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() + } + + override fun onResume() { + super.onResume() + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun filterAndSearch() { + if (binding.searchText.text.toString().isEmpty() && + binding.chipGroup.checkedChipId == View.NO_ID + ) { + gamesViewModel.setSearchedGames(emptyList()) + return + } + + 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() - ChronoField.MILLI_OF_DAY.range().maximum) + } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - ChronoField.MILLI_OF_DAY.range().maximum) + } + } + + R.id.chip_installed -> baseList.filter { it.isInstalled } + + 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/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt new file mode 100644 index 000000000..12fbbee1e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SelectUserDirectoryDialogFragment.kt @@ -0,0 +1,42 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_emu.R +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.viewmodel.HomeViewModel + +class SelectUserDirectoryDialogFragment : DialogFragment() { + private lateinit var mainActivity: MainActivity + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + mainActivity = requireActivity() as MainActivity + + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_citra_user_folder) + .setMessage(R.string.cannot_skip_directory_description) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + mainActivity.openCitraDirectory.launch(null) + } + .show() + } + + companion object { + const val TAG = "SelectUserDirectoryDialogFragment" + + fun newInstance(activity: FragmentActivity): SelectUserDirectoryDialogFragment { + ViewModelProvider(activity)[HomeViewModel::class.java].setPickingUserDir(true) + return SelectUserDirectoryDialogFragment() + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt new file mode 100644 index 000000000..18d94b512 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupFragment.kt @@ -0,0 +1,481 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.Manifest +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +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 androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +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.core.view.updatePadding +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.snackbar.Snackbar +import com.google.android.material.transition.MaterialFadeThrough +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.adapters.SetupAdapter +import org.citra.citra_emu.databinding.FragmentSetupBinding +import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.model.SetupCallback +import org.citra.citra_emu.model.SetupPage +import org.citra.citra_emu.model.StepState +import org.citra.citra_emu.ui.main.MainActivity +import org.citra.citra_emu.utils.CitraDirectoryHelper +import org.citra.citra_emu.utils.GameHelper +import org.citra.citra_emu.utils.PermissionsHandler +import org.citra.citra_emu.utils.ViewUtils +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.HomeViewModel + +class SetupFragment : Fragment() { + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + private lateinit var mainActivity: MainActivity + + private lateinit var hasBeenWarned: BooleanArray + + private lateinit var pages: MutableList + + private val preferences: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + 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) + + pages = mutableListOf() + pages.apply { + add( + SetupPage( + R.drawable.ic_citra_full, + R.string.welcome, + R.string.welcome_description, + 0, + true, + R.string.get_started, + { pageForward() } + ) + ) + + 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, + { + notificationCallback = it + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + false, + true, + { + if (NotificationManagerCompat.from(requireContext()) + .areNotificationsEnabled() + ) { + StepState.STEP_COMPLETE + } else { + StepState.STEP_INCOMPLETE + } + }, + R.string.notification_warning, + R.string.notification_warning_description, + 0 + ) + ) + } + + add( + SetupPage( + R.drawable.ic_microphone, + R.string.microphone_permission, + R.string.microphone_permission_description, + 0, + false, + R.string.give_permission, + { + microphoneCallback = it + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }, + false, + false, + { + if ( + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + ) { + StepState.STEP_COMPLETE + } else { + StepState.STEP_INCOMPLETE + } + } + ) + ) + add( + SetupPage( + R.drawable.ic_camera, + R.string.camera_permission, + R.string.camera_permission_description, + 0, + false, + R.string.give_permission, + { + cameraCallback = it + permissionLauncher.launch(Manifest.permission.CAMERA) + }, + false, + false, + { + if ( + ContextCompat.checkSelfPermission( + requireContext(), + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + StepState.STEP_COMPLETE + } else { + StepState.STEP_INCOMPLETE + } + } + ) + ) + add( + SetupPage( + R.drawable.ic_home, + R.string.select_citra_user_folder, + R.string.select_citra_user_folder_description, + 0, + true, + R.string.select, + { + userDirCallback = it + openCitraDirectory.launch(null) + }, + true, + true, + { + if (PermissionsHandler.hasWriteAccess(requireContext())) { + StepState.STEP_COMPLETE + } else { + StepState.STEP_INCOMPLETE + } + }, + R.string.cannot_skip, + R.string.cannot_skip_directory_description, + R.string.cannot_skip_directory_help + ) + ) + add( + SetupPage( + R.drawable.ic_controller, + R.string.games, + R.string.games_description, + R.drawable.ic_add, + true, + R.string.add_games, + { + gamesDirCallback = it + getGamesDirectory.launch( + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data + ) + }, + false, + true, + { + if (preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()) { + StepState.STEP_COMPLETE + } else { + StepState.STEP_INCOMPLETE + } + }, + R.string.add_games_warning, + R.string.add_games_warning_description, + R.string.add_games_warning_help + ) + ) + add( + SetupPage( + R.drawable.ic_check, + R.string.done, + R.string.done_description, + R.drawable.ic_arrow_forward, + false, + R.string.text_continue, + { finishSetup() } + ) + ) + } + + 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) { + ViewUtils.showView(binding.buttonNext) + ViewUtils.showView(binding.buttonBack) + } else if (position == 0 && previousPosition == 1) { + ViewUtils.hideView(binding.buttonBack) + ViewUtils.hideView(binding.buttonNext) + } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { + ViewUtils.hideView(binding.buttonNext) + } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { + ViewUtils.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 || currentPage.isUnskippable) { + val stepState = currentPage.stepCompleted.invoke() + if (stepState == StepState.STEP_COMPLETE || + stepState == StepState.STEP_UNDEFINED + ) { + pageForward() + return@setOnClickListener + } + + if (currentPage.isUnskippable) { + MessageDialogFragment.newInstance( + currentPage.warningTitleId, + currentPage.warningDescriptionId, + currentPage.warningHelpLinkId + ).show(childFragmentManager, MessageDialogFragment.TAG) + 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 + } + + private lateinit var notificationCallback: SetupCallback + private lateinit var microphoneCallback: SetupCallback + private lateinit var cameraCallback: SetupCallback + + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + val page = pages[binding.viewPager2.currentItem] + when (page.titleId) { + R.string.notifications -> notificationCallback.onStepCompleted() + R.string.microphone_permission -> microphoneCallback.onStepCompleted() + R.string.camera_permission -> cameraCallback.onStepCompleted() + } + return@registerForActivityResult + } + + Snackbar.make(binding.root, R.string.permission_denied, Snackbar.LENGTH_LONG) + .setAnchorView(binding.buttonNext) + .setAction(R.string.grid_menu_core_settings) { + val intent = + Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", requireActivity().packageName, null) + intent.data = uri + startActivity(intent) + } + .show() + } + + private lateinit var userDirCallback: SetupCallback + + private val openCitraDirectory = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { result: Uri? -> + if (result == null) { + return@registerForActivityResult + } + + CitraDirectoryHelper(requireActivity()).showCitraDirectoryDialog(result, userDirCallback) + } + + private lateinit var gamesDirCallback: SetupCallback + + private val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + requireActivity().contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + // When a new directory is picked, we currently will reset the existing games + // database. This effectively means that only one game directory is supported. + preferences.edit() + .putString(GameHelper.KEY_GAME_PATH, result.toString()) + .apply() + + homeViewModel.setGamesDir(requireActivity(), result.path!!) + + gamesDirCallback.onStepCompleted() + } + + private fun finishSetup() { + preferences.edit() + .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) + .apply() + mainActivity.finishSetup(binding.root.findNavController()) + } + + 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, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftPadding = barInsets.left + cutoutInsets.left + val topPadding = barInsets.top + cutoutInsets.top + val rightPadding = barInsets.right + cutoutInsets.right + val bottomPadding = barInsets.bottom + cutoutInsets.bottom + + if (resources.getBoolean(R.bool.small_layout)) { + binding.viewPager2 + .updatePadding(left = leftPadding, top = topPadding, right = rightPadding) + binding.constraintButtons + .updatePadding(left = leftPadding, right = rightPadding, bottom = bottomPadding) + } else { + binding.viewPager2.updatePadding(top = topPadding, bottom = bottomPadding) + binding.constraintButtons + .setPadding( + leftPadding + rightPadding, + topPadding, + rightPadding + leftPadding, + bottomPadding + ) + } + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt new file mode 100644 index 000000000..20a1e0baf --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SetupWarningDialogFragment.kt @@ -0,0 +1,87 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.citra.citra_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(helpLinkId) + 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/citra/citra_emu/fragments/SystemFilesFragment.kt b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt new file mode 100644 index 000000000..3a9f8167c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/SystemFilesFragment.kt @@ -0,0 +1,301 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.fragments + +import android.content.res.Resources +import android.os.Bundle +import android.text.Html +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.databinding.FragmentSystemFilesBinding +import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.HomeViewModel +import org.citra.citra_emu.viewmodel.SystemFilesViewModel + +class SystemFilesFragment : Fragment() { + private var _binding: FragmentSystemFilesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val systemFilesViewModel: SystemFilesViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + private lateinit var regionValues: IntArray + + private val systemTypeDropdown = DropdownItem(R.array.systemFileTypeValues) + private val systemRegionDropdown = DropdownItem(R.array.systemFileRegionValues) + + private val SYS_TYPE = "SysType" + private val REGION = "Region" + private val REGION_START = "RegionStart" + + private val homeMenuMap: MutableMap = mutableMapOf() + + private val WARNING_SHOWN = "SystemFilesWarningShown" + + private class DropdownItem(val valuesId: Int) : AdapterView.OnItemClickListener { + var position = 0 + + fun getValue(resources: Resources): Int { + return resources.getIntArray(valuesId)[position] + } + + override fun onItemClick(p0: AdapterView<*>?, view: View?, position: Int, id: Long) { + this.position = position + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + NativeLibrary.loadSystemConfig() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSystemFilesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + if (!preferences.getBoolean(WARNING_SHOWN, false)) { + MessageDialogFragment.newInstance( + R.string.home_menu_warning, + R.string.home_menu_warning_description + ).show(childFragmentManager, MessageDialogFragment.TAG) + preferences.edit() + .putBoolean(WARNING_SHOWN, true) + .apply() + } + + binding.toolbarSystemFiles.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + // TODO: Remove workaround for text filtering issue in material components when fixed + // https://github.com/material-components/material-components-android/issues/1464 + binding.dropdownSystemType.isSaveEnabled = false + binding.dropdownSystemRegion.isSaveEnabled = false + binding.dropdownSystemRegionStart.isSaveEnabled = false + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + systemFilesViewModel.shouldRefresh.collect { + if (it) { + reloadUi() + systemFilesViewModel.setShouldRefresh(false) + } + } + } + } + + reloadUi() + if (savedInstanceState != null) { + setDropdownSelection( + binding.dropdownSystemType, + systemTypeDropdown, + savedInstanceState.getInt(SYS_TYPE) + ) + setDropdownSelection( + binding.dropdownSystemRegion, + systemRegionDropdown, + savedInstanceState.getInt(REGION) + ) + binding.dropdownSystemRegionStart + .setText(savedInstanceState.getString(REGION_START), false) + } + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putInt(SYS_TYPE, systemTypeDropdown.position) + outState.putInt(REGION, systemRegionDropdown.position) + outState.putString(REGION_START, binding.dropdownSystemRegionStart.text.toString()) + } + + override fun onPause() { + super.onPause() + NativeLibrary.saveSystemConfig() + } + + private fun reloadUi() { + val preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + binding.switchRunSystemSetup.isChecked = NativeLibrary.getIsSystemSetupNeeded() + binding.switchRunSystemSetup.setOnCheckedChangeListener { _, isChecked -> + NativeLibrary.setSystemSetupNeeded(isChecked) + } + + val showHomeApps = preferences.getBoolean(Settings.PREF_SHOW_HOME_APPS, false) + binding.switchShowApps.isChecked = showHomeApps + binding.switchShowApps.setOnCheckedChangeListener { _, isChecked -> + preferences.edit() + .putBoolean(Settings.PREF_SHOW_HOME_APPS, isChecked) + .apply() + gamesViewModel.setShouldSwapData(true) + } + + if (!NativeLibrary.areKeysAvailable()) { + binding.apply { + systemType.isEnabled = false + systemRegion.isEnabled = false + buttonDownloadHomeMenu.isEnabled = false + textKeysMissing.visibility = View.VISIBLE + textKeysMissingHelp.visibility = View.VISIBLE + textKeysMissingHelp.text = + Html.fromHtml(getString(R.string.how_to_get_keys), Html.FROM_HTML_MODE_LEGACY) + textKeysMissingHelp.movementMethod = LinkMovementMethod.getInstance() + } + } else { + populateDownloadOptions() + } + + binding.buttonDownloadHomeMenu.setOnClickListener { + val titleIds = NativeLibrary.getSystemTitleIds( + systemTypeDropdown.getValue(resources), + systemRegionDropdown.getValue(resources) + ) + + DownloadSystemFilesDialogFragment.newInstance(titleIds).show( + childFragmentManager, + DownloadSystemFilesDialogFragment.TAG + ) + } + + populateHomeMenuOptions() + binding.buttonStartHomeMenu.setOnClickListener { + val menuPath = homeMenuMap[binding.dropdownSystemRegionStart.text.toString()]!! + EmulationActivity.launch(requireActivity(), menuPath, getString(R.string.home_menu)) + } + } + + private fun populateDropdown( + dropdown: MaterialAutoCompleteTextView, + valuesId: Int, + dropdownItem: DropdownItem + ) { + val valuesAdapter = ArrayAdapter.createFromResource( + requireContext(), + valuesId, + R.layout.support_simple_spinner_dropdown_item + ) + dropdown.setAdapter(valuesAdapter) + dropdown.onItemClickListener = dropdownItem + } + + private fun setDropdownSelection( + dropdown: MaterialAutoCompleteTextView, + dropdownItem: DropdownItem, + selection: Int + ) { + if (dropdown.adapter != null) { + dropdown.setText(dropdown.adapter.getItem(selection).toString(), false) + } + dropdownItem.position = selection + } + + private fun populateDownloadOptions() { + populateDropdown(binding.dropdownSystemType, R.array.systemFileTypes, systemTypeDropdown) + populateDropdown( + binding.dropdownSystemRegion, + R.array.systemFileRegions, + systemRegionDropdown + ) + + setDropdownSelection( + binding.dropdownSystemType, + systemTypeDropdown, + systemTypeDropdown.position + ) + setDropdownSelection( + binding.dropdownSystemRegion, + systemRegionDropdown, + systemRegionDropdown.position + ) + } + + private fun populateHomeMenuOptions() { + regionValues = resources.getIntArray(R.array.systemFileRegionValues) + val regionEntries = resources.getStringArray(R.array.systemFileRegions) + regionValues.forEachIndexed { i: Int, region: Int -> + val regionString = regionEntries[i] + val regionPath = NativeLibrary.getHomeMenuPath(region) + homeMenuMap[regionString] = regionPath + } + + val availableMenus = homeMenuMap.filter { it.value != "" } + if (availableMenus.isNotEmpty()) { + binding.systemRegionStart.isEnabled = true + binding.buttonStartHomeMenu.isEnabled = true + + binding.dropdownSystemRegionStart.setAdapter( + ArrayAdapter( + requireContext(), + R.layout.support_simple_spinner_dropdown_item, + availableMenus.keys.toList() + ) + ) + binding.dropdownSystemRegionStart.setText(availableMenus.keys.first(), false) + } + } + + 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.toolbarSystemFiles.layoutParams as ViewGroup.MarginLayoutParams + mlpAppBar.leftMargin = leftInsets + mlpAppBar.rightMargin = rightInsets + binding.toolbarSystemFiles.layoutParams = mlpAppBar + + val mlpScrollSystemFiles = + binding.scrollSystemFiles.layoutParams as ViewGroup.MarginLayoutParams + mlpScrollSystemFiles.leftMargin = leftInsets + mlpScrollSystemFiles.rightMargin = rightInsets + binding.scrollSystemFiles.layoutParams = mlpScrollSystemFiles + + binding.scrollSystemFiles.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java deleted file mode 100644 index a4ffc59c7..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.citra.citra_emu.model; - -import android.content.ContentValues; -import android.database.Cursor; - -import java.nio.file.Paths; - -public final class Game { - private String mTitle; - private String mDescription; - private String mPath; - private String mGameId; - private String mCompany; - private String mRegions; - - public Game(String title, String description, String regions, String path, - String gameId, String company) { - mTitle = title; - mDescription = description; - mRegions = regions; - mPath = path; - mGameId = gameId; - mCompany = company; - } - - public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) { - ContentValues values = new ContentValues(); - - if (gameId.isEmpty()) { - // Homebrew, etc. may not have a game ID, use filename as a unique identifier - gameId = Paths.get(path).getFileName().toString(); - } - - values.put(GameDatabase.KEY_GAME_TITLE, title); - values.put(GameDatabase.KEY_GAME_DESCRIPTION, description); - values.put(GameDatabase.KEY_GAME_REGIONS, regions); - values.put(GameDatabase.KEY_GAME_PATH, path); - values.put(GameDatabase.KEY_GAME_ID, gameId); - values.put(GameDatabase.KEY_GAME_COMPANY, company); - - return values; - } - - public static Game fromCursor(Cursor cursor) { - return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE), - cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION), - cursor.getString(GameDatabase.GAME_COLUMN_REGIONS), - cursor.getString(GameDatabase.GAME_COLUMN_PATH), - cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID), - cursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); - } - - public String getTitle() { - return mTitle; - } - - public String getDescription() { - return mDescription; - } - - public String getCompany() { - return mCompany; - } - - public String getRegions() { - return mRegions; - } - - public String getPath() { - return mPath; - } - - public String getGameId() { - return mGameId; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt new file mode 100644 index 000000000..d26d730d0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.kt @@ -0,0 +1,59 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +import android.os.Parcelable +import java.util.HashSet +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +class Game( + val title: String = "", + val description: String = "", + val path: String = "", + val titleId: Long = 0L, + val company: String = "", + val regions: String = "", + val isInstalled: Boolean = false, + val isSystemTitle: Boolean = false, + val isVisibleSystemTitle: Boolean = false, + val icon: IntArray? = null, + val filename: String +) : Parcelable { + val keyAddedToLibraryTime get() = "${filename}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${filename}_LastPlayed" + + override fun equals(other: Any?): Boolean { + if (other !is Game) { + return false + } + + return hashCode() == other.hashCode() + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + description.hashCode() + result = 31 * result + regions.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + titleId.hashCode() + result = 31 * result + company.hashCode() + return result + } + + companion object { + val allExtensions: Set get() = extensions + badExtensions + + val extensions: Set = HashSet( + listOf("3ds", "3dsx", "elf", "axf", "cci", "cxi", "app") + ) + + val badExtensions: Set = HashSet( + listOf("rar", "zip", "7z", "torrent", "tar", "gz") + ) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java deleted file mode 100644 index cbbd8e32e..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java +++ /dev/null @@ -1,279 +0,0 @@ -package org.citra.citra_emu.model; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteOpenHelper; -import android.net.Uri; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.utils.FileUtil; -import org.citra.citra_emu.utils.Log; - -import java.io.File; -import java.io.IOException; -import java.lang.reflect.Array; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -import rx.Observable; - -/** - * A helper class that provides several utilities simplifying interaction with - * the SQLite database. - */ -public final class GameDatabase extends SQLiteOpenHelper { - public static final int COLUMN_DB_ID = 0; - public static final int GAME_COLUMN_PATH = 1; - public static final int GAME_COLUMN_TITLE = 2; - public static final int GAME_COLUMN_DESCRIPTION = 3; - public static final int GAME_COLUMN_REGIONS = 4; - public static final int GAME_COLUMN_GAME_ID = 5; - public static final int GAME_COLUMN_COMPANY = 6; - public static final int FOLDER_COLUMN_PATH = 1; - public static final String KEY_DB_ID = "_id"; - public static final String KEY_GAME_PATH = "path"; - public static final String KEY_GAME_TITLE = "title"; - public static final String KEY_GAME_DESCRIPTION = "description"; - public static final String KEY_GAME_REGIONS = "regions"; - public static final String KEY_GAME_ID = "game_id"; - public static final String KEY_GAME_COMPANY = "company"; - public static final String KEY_FOLDER_PATH = "path"; - public static final String TABLE_NAME_FOLDERS = "folders"; - public static final String TABLE_NAME_GAMES = "games"; - private static final int DB_VERSION = 2; - private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY"; - private static final String TYPE_INTEGER = " INTEGER"; - private static final String TYPE_STRING = " TEXT"; - - private static final String CONSTRAINT_UNIQUE = " UNIQUE"; - - private static final String SEPARATOR = ", "; - - private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "(" - + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR - + KEY_GAME_PATH + TYPE_STRING + SEPARATOR - + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR - + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR - + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR - + KEY_GAME_ID + TYPE_STRING + SEPARATOR - + KEY_GAME_COMPANY + TYPE_STRING + ")"; - - private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "(" - + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR - + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")"; - - private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; - private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; - private final Context mContext; - - public GameDatabase(Context context) { - // Superclass constructor builds a database or uses an existing one. - super(context, "games.db", null, DB_VERSION); - mContext = context; - } - - @Override - public void onCreate(SQLiteDatabase database) { - Log.debug("[GameDatabase] GameDatabase - Creating database..."); - - execSqlAndLog(database, SQL_CREATE_GAMES); - execSqlAndLog(database, SQL_CREATE_FOLDERS); - } - - @Override - public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) { - Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases.."); - execSqlAndLog(database, SQL_DELETE_FOLDERS); - execSqlAndLog(database, SQL_CREATE_FOLDERS); - - execSqlAndLog(database, SQL_DELETE_GAMES); - execSqlAndLog(database, SQL_CREATE_GAMES); - } - - @Override - public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { - Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " + - newVersion); - - // Delete all the games - execSqlAndLog(database, SQL_DELETE_GAMES); - execSqlAndLog(database, SQL_CREATE_GAMES); - } - - public void resetDatabase(SQLiteDatabase database) { - execSqlAndLog(database, SQL_DELETE_FOLDERS); - execSqlAndLog(database, SQL_CREATE_FOLDERS); - - execSqlAndLog(database, SQL_DELETE_GAMES); - execSqlAndLog(database, SQL_CREATE_GAMES); - } - - public void scanLibrary(SQLiteDatabase database) { - // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing. - Cursor fileCursor = database.query(TABLE_NAME_GAMES, - null, // Get all columns. - null, // Get all rows. - null, - null, // No grouping. - null, - null); // Order of games is irrelevant. - - // Possibly overly defensive, but ensures that moveToNext() does not skip a row. - fileCursor.moveToPosition(-1); - - while (fileCursor.moveToNext()) { - String gamePath = fileCursor.getString(GAME_COLUMN_PATH); - - if (!FileUtil.Exists(mContext, gamePath)) { - Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " + - gamePath); - database.delete(TABLE_NAME_GAMES, - KEY_DB_ID + " = ?", - new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))}); - } - } - - // Get a cursor listing all the folders the user has added to the library. - Cursor folderCursor = database.query(TABLE_NAME_FOLDERS, - null, // Get all columns. - null, // Get all rows. - null, - null, // No grouping. - null, - null); // Order of folders is irrelevant. - - Set allowedExtensions = new HashSet(Arrays.asList( - ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app", ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz")); - - // Possibly overly defensive, but ensures that moveToNext() does not skip a row. - folderCursor.moveToPosition(-1); - - // Iterate through all results of the DB query (i.e. all folders in the library.) - while (folderCursor.moveToNext()) { - String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); - - Uri folder = Uri.parse(folderPath); - // If the folder is empty because it no longer exists, remove it from the library. - CheapDocument[] files = FileUtil.listFiles(mContext, folder); - if (files.length == 0) { - Log.error( - "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); - database.delete(TABLE_NAME_FOLDERS, - KEY_DB_ID + " = ?", - new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); - } - - addGamesRecursive(database, files, allowedExtensions, 3); - } - - fileCursor.close(); - folderCursor.close(); - - Arrays.stream(NativeLibrary.GetInstalledGamePaths()) - .forEach(filePath -> attemptToAddGame(database, filePath)); - - database.close(); - } - - private void addGamesRecursive(SQLiteDatabase database, CheapDocument[] files, - Set allowedExtensions, int depth) { - if (depth <= 0) { - return; - } - - for (CheapDocument file : files) { - if (file.isDirectory()) { - Set newExtensions = new HashSet<>(Arrays.asList( - ".3ds", ".3dsx", ".elf", ".axf", ".cci", ".cxi", ".app")); - CheapDocument[] children = FileUtil.listFiles(mContext, file.getUri()); - this.addGamesRecursive(database, children, newExtensions, depth - 1); - } else { - String filename = file.getUri().toString(); - - int extensionStart = filename.lastIndexOf('.'); - if (extensionStart > 0) { - String fileExtension = filename.substring(extensionStart); - - // Check that the file has an extension we care about before trying to read out of it. - if (allowedExtensions.contains(fileExtension.toLowerCase())) { - attemptToAddGame(database, filename); - } - } - } - } - } - - private static void attemptToAddGame(SQLiteDatabase database, String filePath) { - GameInfo gameInfo; - try { - gameInfo = new GameInfo(filePath); - } catch (IOException e) { - gameInfo = null; - } - - String name = gameInfo != null ? gameInfo.getTitle() : ""; - - // If the game's title field is empty, use the filename. - if (name.isEmpty()) { - name = filePath.substring(filePath.lastIndexOf("/") + 1); - } - - ContentValues game = Game.asContentValues(name, - filePath.replace("\n", " "), - gameInfo != null ? gameInfo.getRegions() : "Invalid region", - filePath, - filePath, - gameInfo != null ? gameInfo.getCompany() : ""); - - // Try to update an existing game first. - int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update. - game, - // The values to fill the row with. - KEY_GAME_ID + " = ?", - // The WHERE clause used to find the right row. - new String[]{game.getAsString( - KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this, - // which is provided as an array because there - // could potentially be more than one argument. - - // If update fails, insert a new game instead. - if (rowsMatched == 0) { - Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE)); - database.insert(TABLE_NAME_GAMES, null, game); - } else { - Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE)); - } - } - - public Observable getGames() { - return Observable.create(subscriber -> - { - Log.info("[GameDatabase] Reading games list..."); - - SQLiteDatabase database = getReadableDatabase(); - Cursor resultCursor = database.query( - TABLE_NAME_GAMES, - null, - null, - null, - null, - null, - KEY_GAME_TITLE + " ASC" - ); - - // Pass the result cursor to the consumer. - subscriber.onNext(resultCursor); - - // Tell the consumer we're done; it will unsubscribe implicitly. - subscriber.onCompleted(); - }); - } - - private void execSqlAndLog(SQLiteDatabase database, String sql) { - Log.verbose("[GameDatabase] Executing SQL: " + sql); - database.execSQL(sql); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java deleted file mode 100644 index 35ce9947c..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.citra.citra_emu.model; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.IOException; - -public class GameInfo { - @Keep - private final long mPointer; - - @Keep - public GameInfo(String path) throws IOException { - mPointer = initialize(path); - if (mPointer == 0L) { - throw new IOException(); - } - } - - private static native long initialize(String path); - - @Override - protected native void finalize(); - - @NonNull - public native String getTitle(); - - @NonNull - public native String getRegions(); - - @NonNull - public native String getCompany(); - - @Nullable - public native int[] getIcon(); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt new file mode 100644 index 000000000..de2c1860c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameInfo.kt @@ -0,0 +1,37 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +import androidx.annotation.Keep +import java.io.IOException + +class GameInfo(path: String) { + @Keep + private val pointer: Long + + init { + pointer = initialize(path) + if (pointer == 0L) { + throw IOException() + } + } + + protected external fun finalize() + + external fun getTitle(): String + + external fun getRegions(): String + + external fun getCompany(): String + + external fun getIcon(): IntArray? + + external fun getIsVisibleSystemTitle(): Boolean + + companion object { + @JvmStatic + private external fun initialize(path: String): Long + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java deleted file mode 100644 index 33b289fc4..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java +++ /dev/null @@ -1,138 +0,0 @@ -package org.citra.citra_emu.model; - -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.net.Uri; - -import androidx.annotation.NonNull; - -import org.citra.citra_emu.BuildConfig; -import org.citra.citra_emu.utils.Log; - -/** - * Provides an interface allowing Activities to interact with the SQLite database. - * CRUD methods in this class can be called by Activities using getContentResolver(). - */ -public final class GameProvider extends ContentProvider { - public static final String REFRESH_LIBRARY = "refresh"; - public static final String RESET_LIBRARY = "reset"; - - public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider"; - public static final Uri URI_FOLDER = - Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/"); - public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/"); - public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/"); - - public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder"; - public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game"; - - - private GameDatabase mDbHelper; - - @Override - public boolean onCreate() { - Log.info("[GameProvider] Creating Content Provider..."); - - mDbHelper = new GameDatabase(getContext()); - - return true; - } - - @Override - public Cursor query(@NonNull Uri uri, String[] projection, String selection, - String[] selectionArgs, String sortOrder) { - Log.info("[GameProvider] Querying URI: " + uri); - - SQLiteDatabase db = mDbHelper.getReadableDatabase(); - - String table = uri.getLastPathSegment(); - - if (table == null) { - Log.error("[GameProvider] Badly formatted URI: " + uri); - return null; - } - - Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder); - cursor.setNotificationUri(getContext().getContentResolver(), uri); - - return cursor; - } - - @Override - public String getType(@NonNull Uri uri) { - Log.verbose("[GameProvider] Getting MIME type for URI: " + uri); - String lastSegment = uri.getLastPathSegment(); - - if (lastSegment == null) { - Log.error("[GameProvider] Badly formatted URI: " + uri); - return null; - } - - if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) { - return MIME_TYPE_FOLDER; - } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) { - return MIME_TYPE_GAME; - } - - Log.error("[GameProvider] Unknown MIME type for URI: " + uri); - return null; - } - - @Override - public Uri insert(@NonNull Uri uri, ContentValues values) { - Log.info("[GameProvider] Inserting row at URI: " + uri); - - SQLiteDatabase database = mDbHelper.getWritableDatabase(); - String table = uri.getLastPathSegment(); - - if (table != null) { - if (table.equals(RESET_LIBRARY)) { - mDbHelper.resetDatabase(database); - return uri; - } - if (table.equals(REFRESH_LIBRARY)) { - Log.info( - "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents..."); - mDbHelper.scanLibrary(database); - return uri; - } - - long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE); - - // If insertion was successful... - if (id > 0) { - // If we just added a folder, add its contents to the game list. - if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) { - mDbHelper.scanLibrary(database); - } - - // Notify the UI that its contents should be refreshed. - getContext().getContentResolver().notifyChange(uri, null); - uri = Uri.withAppendedPath(uri, Long.toString(id)); - } else { - Log.error("[GameProvider] Row already exists: " + uri + " id: " + id); - } - } else { - Log.error("[GameProvider] Badly formatted URI: " + uri); - } - - database.close(); - - return uri; - } - - @Override - public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { - Log.error("[GameProvider] Delete operations unsupported. URI: " + uri); - return 0; - } - - @Override - public int update(@NonNull Uri uri, ContentValues values, String selection, - String[] selectionArgs) { - Log.error("[GameProvider] Update operations unsupported. URI: " + uri); - return 0; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt new file mode 100644 index 000000000..70a45c0ed --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/HomeSetting.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +data class HomeSetting( + val titleId: Int, + val descriptionId: Int, + val iconId: Int, + val onClick: () -> Unit, + val isEnabled: () -> Boolean = { true }, + val disabledTitleId: Int = 0, + val disabledMessageId: Int = 0, + val details: StateFlow = MutableStateFlow("") +) diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt new file mode 100644 index 000000000..b4115afe5 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/License.kt @@ -0,0 +1,19 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +import android.os.Parcelable +import androidx.annotation.StringRes +import kotlinx.parcelize.Parcelize + +@Parcelize +data class License( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + @StringRes val linkId: Int, + @StringRes val copyrightId: Int = 0, + @StringRes val licenseId: Int = 0, + @StringRes val licenseLinkId: Int = 0 +) : Parcelable diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt new file mode 100644 index 000000000..c45f05cf8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/SetupPage.kt @@ -0,0 +1,31 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.model + +data class SetupPage( + val iconId: Int, + val titleId: Int, + val descriptionId: Int, + val buttonIconId: Int, + val leftAlignedIcon: Boolean, + val buttonTextId: Int, + val buttonAction: (callback: SetupCallback) -> Unit, + val isUnskippable: Boolean = false, + val hasWarning: Boolean = false, + val stepCompleted: () -> StepState = { StepState.STEP_UNDEFINED }, + val warningTitleId: Int = 0, + val warningDescriptionId: Int = 0, + val warningHelpLinkId: Int = 0 +) + +interface SetupCallback { + fun onStepCompleted() +} + +enum class StepState { + STEP_COMPLETE, + STEP_INCOMPLETE, + STEP_UNDEFINED +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java index f3e18afb2..e4d8da791 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java @@ -347,7 +347,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { if (!button.updateStatus(event)) { continue; } - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus()); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, button.getId(), button.getStatus()); shouldUpdateView = true; } @@ -355,10 +355,10 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { if (!dpad.updateStatus(event, EmulationMenuSettings.getDpadSlideEnable())) { continue; } - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus()); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus()); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus()); - NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus()); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getUpId(), dpad.getUpStatus()); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getDownId(), dpad.getDownStatus()); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getLeftId(), dpad.getLeftStatus()); + NativeLibrary.INSTANCE.onGamePadEvent(NativeLibrary.TouchScreenDevice, dpad.getRightId(), dpad.getRightStatus()); shouldUpdateView = true; } @@ -367,7 +367,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { continue; } int axisID = joystick.getJoystickId(); - NativeLibrary + NativeLibrary.INSTANCE .onGamePadMoveEvent(NativeLibrary.TouchScreenDevice, axisID, joystick.getXAxis(), joystick.getYAxis()); shouldUpdateView = true; } @@ -390,7 +390,7 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { boolean isActionUp = motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP; if (isActionDown && !isTouchInputConsumed(pointerId)) { - NativeLibrary.onTouchEvent(xPosition, yPosition, true); + NativeLibrary.INSTANCE.onTouchEvent(xPosition, yPosition, true); } if (isActionMove) { @@ -399,12 +399,12 @@ public final class InputOverlay extends SurfaceView implements OnTouchListener { if (isTouchInputConsumed(fingerId)) { continue; } - NativeLibrary.onTouchMoved(xPosition, yPosition); + NativeLibrary.INSTANCE.onTouchMoved(xPosition, yPosition); } } if (isActionUp && !isTouchInputConsumed(pointerId)) { - NativeLibrary.onTouchEvent(0, 0, false); + NativeLibrary.INSTANCE.onTouchEvent(0, 0, false); } return true; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java deleted file mode 100644 index 0c295541c..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java +++ /dev/null @@ -1,334 +0,0 @@ -package org.citra.citra_emu.ui.main; - -import android.Manifest; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.widget.FrameLayout; -import android.widget.Toast; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; -import androidx.core.splashscreen.SplashScreen; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import java.util.Collections; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.work.Data; -import androidx.work.ExistingWorkPolicy; -import androidx.work.OneTimeWorkRequest; -import androidx.work.OutOfQuotaPolicy; -import androidx.work.WorkManager; -import androidx.work.WorkRequest; - -import com.google.android.material.appbar.AppBarLayout; - -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; -import org.citra.citra_emu.contracts.OpenFileResultContract; -import org.citra.citra_emu.features.settings.ui.SettingsActivity; -import org.citra.citra_emu.model.GameProvider; -import org.citra.citra_emu.ui.platform.PlatformGamesFragment; -import org.citra.citra_emu.utils.AddDirectoryHelper; -import org.citra.citra_emu.utils.BillingManager; -import org.citra.citra_emu.utils.CiaInstallWorker; -import org.citra.citra_emu.utils.CitraDirectoryHelper; -import org.citra.citra_emu.utils.DirectoryInitialization; -import org.citra.citra_emu.utils.FileBrowserHelper; -import org.citra.citra_emu.utils.InsetsHelper; -import org.citra.citra_emu.utils.PermissionsHandler; -import org.citra.citra_emu.utils.PicassoUtils; -import org.citra.citra_emu.utils.StartupHandler; -import org.citra.citra_emu.utils.ThemeUtil; - -/** - * The main Activity of the Lollipop style UI. Manages several PlatformGamesFragments, which - * individually display a grid of available games for each Fragment, in a tabbed layout. - */ -public final class MainActivity extends AppCompatActivity implements MainView { - private Toolbar mToolbar; - private int mFrameLayoutId; - private PlatformGamesFragment mPlatformGamesFragment; - - private final MainPresenter mPresenter = new MainPresenter(this); - - // private final CiaInstallWorker mCiaInstallWorker = new CiaInstallWorker(); - - // Singleton to manage user billing state - private static BillingManager mBillingManager; - - private static MenuItem mPremiumButton; - - private final CitraDirectoryHelper citraDirectoryHelper = new CitraDirectoryHelper(this, () -> { - // If mPlatformGamesFragment is null means game directory have not been set yet. - if (mPlatformGamesFragment == null) { - mPlatformGamesFragment = new PlatformGamesFragment(); - getSupportFragmentManager() - .beginTransaction() - .add(mFrameLayoutId, mPlatformGamesFragment) - .commit(); - showGameInstallDialog(); - } - }); - - private final ActivityResultLauncher mOpenCitraDirectory = - registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> { - if (result == null) - return; - citraDirectoryHelper.showCitraDirectoryDialog(result); - }); - - private final ActivityResultLauncher mOpenGameListLauncher = - registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), result -> { - if (result == null) - return; - int takeFlags = - (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - getContentResolver().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. - // TODO(bunnei): Consider fixing this in the future, or removing code for this. - getContentResolver().insert(GameProvider.URI_RESET, null); - // Add the new directory - mPresenter.onDirectorySelected(result.toString()); - }); - - private final ActivityResultLauncher mInstallCiaFileLauncher = - registerForActivityResult(new OpenFileResultContract(), result -> { - if (result == null) - return; - String[] selectedFiles = FileBrowserHelper.getSelectedFiles( - result, getApplicationContext(), Collections.singletonList("cia")); - if (selectedFiles == null) { - Toast - .makeText(getApplicationContext(), R.string.cia_file_not_found, - Toast.LENGTH_LONG) - .show(); - return; - } - WorkManager workManager = WorkManager.getInstance(getApplicationContext()); - workManager.enqueueUniqueWork("installCiaWork", ExistingWorkPolicy.APPEND_OR_REPLACE, - new OneTimeWorkRequest.Builder(CiaInstallWorker.class) - .setInputData( - new Data.Builder().putStringArray("CIA_FILES", selectedFiles) - .build() - ) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - ); - }); - - private final ActivityResultLauncher requestNotificationPermissionLauncher = - registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> { }); - - @Override - protected void onCreate(Bundle savedInstanceState) { - SplashScreen splashScreen = SplashScreen.installSplashScreen(this); - splashScreen.setKeepOnScreenCondition( - () - -> (PermissionsHandler.hasWriteAccess(this) && - !DirectoryInitialization.areCitraDirectoriesReady())); - - ThemeUtil.applyTheme(this); - - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - - findViews(); - - setSupportActionBar(mToolbar); - - mFrameLayoutId = R.id.games_platform_frame; - mPresenter.onCreate(); - - if (savedInstanceState == null) { - StartupHandler.HandleInit(this, mOpenCitraDirectory); - if (PermissionsHandler.hasWriteAccess(this)) { - mPlatformGamesFragment = new PlatformGamesFragment(); - getSupportFragmentManager().beginTransaction().add(mFrameLayoutId, mPlatformGamesFragment) - .commit(); - } - } else { - mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment"); - } - PicassoUtils.init(); - - // Setup billing manager, so we can globally query for Premium status - mBillingManager = new BillingManager(this); - - // Dismiss previous notifications (should not happen unless a crash occurred) - EmulationActivity.tryDismissRunningNotification(this); - - setInsets(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS); - } - } - } - - @Override - protected void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - if (PermissionsHandler.hasWriteAccess(this)) { - if (getSupportFragmentManager() == null) { - return; - } - if (outState == null) { - return; - } - getSupportFragmentManager().putFragment(outState, "mPlatformGamesFragment", mPlatformGamesFragment); - } - } - - @Override - protected void onResume() { - super.onResume(); - mPresenter.addDirIfNeeded(new AddDirectoryHelper(this)); - - ThemeUtil.setSystemBarMode(this, ThemeUtil.getIsLightMode(getResources())); - } - - // TODO: Replace with a ButterKnife injection. - private void findViews() { - mToolbar = findViewById(R.id.toolbar_main); - } - - @Override - public boolean onCreateOptionsMenu(Menu menu) { - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.menu_game_grid, menu); - mPremiumButton = menu.findItem(R.id.button_premium); - - if (mBillingManager.isPremiumCached()) { - // User had premium in a previous session, hide upsell option - setPremiumButtonVisible(false); - } - - return true; - } - - static public void setPremiumButtonVisible(boolean isVisible) { - if (mPremiumButton != null) { - mPremiumButton.setVisible(isVisible); - } - } - - /** - * MainView - */ - - @Override - public void setVersionString(String version) { - mToolbar.setSubtitle(version); - } - - @Override - public void refresh() { - getContentResolver().insert(GameProvider.URI_REFRESH, null); - refreshFragment(); - } - - @Override - public void launchSettingsActivity(String menuTag) { - if (PermissionsHandler.hasWriteAccess(this)) { - SettingsActivity.launch(this, menuTag, ""); - } else { - PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory); - } - } - - @Override - public void launchFileListActivity(int request) { - if (PermissionsHandler.hasWriteAccess(this)) { - switch (request) { - case MainPresenter.REQUEST_SELECT_CITRA_DIRECTORY: - mOpenCitraDirectory.launch(null); - break; - case MainPresenter.REQUEST_ADD_DIRECTORY: - mOpenGameListLauncher.launch(null); - break; - case MainPresenter.REQUEST_INSTALL_CIA: - mInstallCiaFileLauncher.launch(true); - break; - } - } else { - PermissionsHandler.checkWritePermission(this, mOpenCitraDirectory); - } - } - - /** - * Called by the framework whenever any actionbar/toolbar icon is clicked. - * - * @param item The icon that was clicked on. - * @return True if the event was handled, false to bubble it up to the OS. - */ - @Override - public boolean onOptionsItemSelected(MenuItem item) { - return mPresenter.handleOptionSelection(item.getItemId()); - } - - private void refreshFragment() { - if (mPlatformGamesFragment != null) { - mPlatformGamesFragment.refresh(); - } - } - - private void showGameInstallDialog() { - new MaterialAlertDialogBuilder(this) - .setIcon(R.mipmap.ic_launcher) - .setTitle(R.string.app_name) - .setMessage(R.string.app_game_install_description) - .setCancelable(false) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, - (d, v) -> mOpenGameListLauncher.launch(null)) - .show(); - } - - @Override - protected void onDestroy() { - EmulationActivity.tryDismissRunningNotification(this); - super.onDestroy(); - } - - /** - * @return true if Premium subscription is currently active - */ - public static boolean isPremiumActive() { - return mBillingManager.isPremiumActive(); - } - - /** - * Invokes the billing flow for Premium - * - * @param callback Optional callback, called once, on completion of billing - */ - public static void invokePremiumBilling(Runnable callback) { - mBillingManager.invokePremiumBilling(callback); - } - - private void setInsets() { - AppBarLayout appBar = findViewById(R.id.appbar); - FrameLayout frame = findViewById(R.id.games_platform_frame); - ViewCompat.setOnApplyWindowInsetsListener(frame, (v, windowInsets) -> { - Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - InsetsHelper.insetAppBar(insets, appBar); - frame.setPadding(insets.left, 0, insets.right, 0); - return windowInsets; - }); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt new file mode 100644 index 000000000..cb198f31e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.kt @@ -0,0 +1,327 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.ui.main + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager +import android.view.animation.PathInterpolator +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import androidx.preference.PreferenceManager +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.navigation.NavigationBarView +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import org.citra.citra_emu.R +import org.citra.citra_emu.activities.EmulationActivity +import org.citra.citra_emu.contracts.OpenFileResultContract +import org.citra.citra_emu.databinding.ActivityMainBinding +import org.citra.citra_emu.features.settings.model.Settings +import org.citra.citra_emu.features.settings.ui.SettingsActivity +import org.citra.citra_emu.features.settings.utils.SettingsFile +import org.citra.citra_emu.fragments.SelectUserDirectoryDialogFragment +import org.citra.citra_emu.utils.CiaInstallWorker +import org.citra.citra_emu.utils.CitraDirectoryHelper +import org.citra.citra_emu.utils.DirectoryInitialization +import org.citra.citra_emu.utils.FileBrowserHelper +import org.citra.citra_emu.utils.InsetsHelper +import org.citra.citra_emu.utils.PermissionsHandler +import org.citra.citra_emu.utils.ThemeUtil +import org.citra.citra_emu.viewmodel.GamesViewModel +import org.citra.citra_emu.viewmodel.HomeViewModel + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { + !DirectoryInitialization.areCitraDirectoriesReady() && + PermissionsHandler.hasWriteAccess(this) + } + + ThemeUtil.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( + ThemeUtil.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeUtil.SYSTEM_BAR_ALPHA + ) + ) + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeUtil.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeUtil.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 + } + + lifecycleScope.apply { + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.navigationVisible.collect { + showNavigation(it.first, it.second) + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.statusBarShadeVisible.collect { + showStatusBarShade(it) + } + } + } + launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + homeViewModel.isPickingUserDir.collect { checkUserPermissions() } + } + } + } + + // Dismiss previous notifications (should not happen unless a crash occurred) + EmulationActivity.tryDismissRunningNotification(this) + + setInsets() + } + + override fun onResume() { + checkUserPermissions() + super.onResume() + } + + override fun onDestroy() { + EmulationActivity.tryDismissRunningNotification(this) + super.onDestroy() + } + + private fun checkUserPermissions() { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + + if (!firstTimeSetup && !PermissionsHandler.hasWriteAccess(this) && + !homeViewModel.isPickingUserDir.value + ) { + SelectUserDirectoryDialogFragment.newInstance(this) + .show(supportFragmentManager, SelectUserDirectoryDialogFragment.TAG) + } + } + + fun finishSetup(navController: NavController) { + navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + } + + 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() + } + + 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 + } + + val openCitraDirectory = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { result: Uri? -> + if (result == null) { + return@registerForActivityResult + } + + CitraDirectoryHelper(this@MainActivity).showCitraDirectoryDialog(result) + } + + val ciaFileInstaller = registerForActivityResult( + OpenFileResultContract() + ) { result: Intent? -> + if (result == null) { + return@registerForActivityResult + } + + val selectedFiles = + FileBrowserHelper.getSelectedFiles(result, applicationContext, listOf("cia")) + if (selectedFiles == null) { + Toast.makeText(applicationContext, R.string.cia_file_not_found, Toast.LENGTH_LONG) + .show() + return@registerForActivityResult + } + + val workManager = WorkManager.getInstance(applicationContext) + workManager.enqueueUniqueWork( + "installCiaWork", ExistingWorkPolicy.APPEND_OR_REPLACE, + OneTimeWorkRequest.Builder(CiaInstallWorker::class.java) + .setInputData( + Data.Builder().putStringArray("CIA_FILES", selectedFiles) + .build() + ) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + ) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java deleted file mode 100644 index b25cbe53fe..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.citra.citra_emu.ui.main; - -import android.content.Context; -import android.os.SystemClock; - -import org.citra.citra_emu.BuildConfig; -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.model.Settings; -import org.citra.citra_emu.features.settings.utils.SettingsFile; -import org.citra.citra_emu.model.GameDatabase; -import org.citra.citra_emu.utils.AddDirectoryHelper; -import org.citra.citra_emu.utils.PermissionsHandler; - -public final class MainPresenter { - public static final int REQUEST_ADD_DIRECTORY = 1; - public static final int REQUEST_INSTALL_CIA = 2; - public static final int REQUEST_SELECT_CITRA_DIRECTORY = 3; - - private final MainView mView; - private String mDirToAdd; - private long mLastClickTime = 0; - - public MainPresenter(MainView view) { - mView = view; - } - - public void onCreate() { - String versionName = BuildConfig.VERSION_NAME; - mView.setVersionString(versionName); - refreshGameList(); - } - - public void launchFileListActivity(int request) { - if (mView != null) { - mView.launchFileListActivity(request); - } - } - - public boolean handleOptionSelection(int itemId) { - // Double-click prevention, using threshold of 500 ms - if (SystemClock.elapsedRealtime() - mLastClickTime < 500) { - return false; - } - mLastClickTime = SystemClock.elapsedRealtime(); - - switch (itemId) { - case R.id.menu_settings_core: - mView.launchSettingsActivity(SettingsFile.FILE_NAME_CONFIG); - return true; - - case R.id.button_select_root: - mView.launchFileListActivity(REQUEST_SELECT_CITRA_DIRECTORY); - return true; - - case R.id.button_add_directory: - launchFileListActivity(REQUEST_ADD_DIRECTORY); - return true; - - case R.id.button_install_cia: - launchFileListActivity(REQUEST_INSTALL_CIA); - return true; - - case R.id.button_premium: - mView.launchSettingsActivity(Settings.SECTION_PREMIUM); - return true; - } - - return false; - } - - public void addDirIfNeeded(AddDirectoryHelper helper) { - if (mDirToAdd != null) { - helper.addDirectory(mDirToAdd, mView::refresh); - - mDirToAdd = null; - } - } - - public void onDirectorySelected(String dir) { - mDirToAdd = dir; - } - - public void refreshGameList() { - Context context = CitraApplication.getAppContext(); - if (PermissionsHandler.hasWriteAccess(context)) { - GameDatabase databaseHelper = CitraApplication.databaseHelper; - databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); - mView.refresh(); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java deleted file mode 100644 index de7c04875..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.citra.citra_emu.ui.main; - -/** - * Abstraction for the screen that shows on application launch. - * Implementations will differ primarily to target touch-screen - * or non-touch screen devices. - */ -public interface MainView { - /** - * Pass the view the native library's version string. Displaying - * it is optional. - * - * @param version A string pulled from native code. - */ - void setVersionString(String version); - - /** - * Tell the view to refresh its contents. - */ - void refresh(); - - void launchSettingsActivity(String menuTag); - - void launchFileListActivity(int request); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java deleted file mode 100644 index 8ff938bee..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java +++ /dev/null @@ -1,127 +0,0 @@ -package org.citra.citra_emu.ui.platform; - - -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -import android.database.Cursor; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.Fragment; -import androidx.lifecycle.Lifecycle; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; - -import com.google.android.material.color.MaterialColors; -import com.google.android.material.divider.MaterialDividerItemDecoration; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; -import org.citra.citra_emu.adapters.GameAdapter; -import org.citra.citra_emu.model.GameDatabase; - -public final class PlatformGamesFragment extends Fragment implements PlatformGamesView { - private final PlatformGamesPresenter mPresenter = new PlatformGamesPresenter(this); - - private GameAdapter mAdapter; - private RecyclerView mRecyclerView; - private TextView mTextView; - private SwipeRefreshLayout mPullToRefresh; - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - } - - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - View rootView = inflater.inflate(R.layout.fragment_grid, container, false); - - findViews(rootView); - - mPresenter.onCreateView(); - - return rootView; - } - - private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); - private final Handler mHandler = new Handler(Looper.getMainLooper()); - - private void onPullToRefresh() { - Runnable onPostRunnable = () -> { - updateTextView(); - mPullToRefresh.setRefreshing(false); - }; - Runnable scanLibraryRunnable = () -> { - GameDatabase databaseHelper = CitraApplication.databaseHelper; - databaseHelper.scanLibrary(databaseHelper.getWritableDatabase()); - mPresenter.refresh(); - mHandler.post(onPostRunnable); - }; - - mExecutor.execute(scanLibraryRunnable); - } - - @Override - public void onViewCreated(View view, Bundle savedInstanceState) { - int columns = getResources().getInteger(R.integer.game_grid_columns); - RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns); - mAdapter = new GameAdapter(); - - mRecyclerView.setLayoutManager(layoutManager); - mRecyclerView.setAdapter(mAdapter); - MaterialDividerItemDecoration divider = new MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL); - divider.setLastItemDecorated(false); - mRecyclerView.addItemDecoration(divider); - - // Add swipe down to refresh gesture - mPullToRefresh.setOnRefreshListener(this::onPullToRefresh); - mPullToRefresh.setProgressBackgroundColorSchemeColor(MaterialColors.getColor(mPullToRefresh, R.attr.colorPrimary)); - mPullToRefresh.setColorSchemeColors(MaterialColors.getColor(mPullToRefresh, R.attr.colorOnPrimary)); - - setInsets(); - } - - @Override - public void refresh() { - mPresenter.refresh(); - updateTextView(); - } - - @Override - public void showGames(Cursor games) { - if (mAdapter != null) { - mAdapter.swapCursor(games); - } - updateTextView(); - } - - private void updateTextView() { - mTextView.setVisibility(mAdapter.getItemCount() == 0 ? View.VISIBLE : View.GONE); - } - - private void findViews(View root) { - mRecyclerView = root.findViewById(R.id.grid_games); - mTextView = root.findViewById(R.id.gamelist_empty_text); - mPullToRefresh = root.findViewById(R.id.refresh_grid_games); - } - - private void setInsets() { - ViewCompat.setOnApplyWindowInsetsListener(mRecyclerView, (v, windowInsets) -> { - Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); - v.setPadding(0, 0, 0, insets.bottom); - return windowInsets; - }); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java deleted file mode 100644 index 9d8040e1b..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.citra.citra_emu.ui.platform; - - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.model.GameDatabase; -import org.citra.citra_emu.utils.Log; - -import rx.android.schedulers.AndroidSchedulers; -import rx.schedulers.Schedulers; - -public final class PlatformGamesPresenter { - private final PlatformGamesView mView; - - public PlatformGamesPresenter(PlatformGamesView view) { - mView = view; - } - - public void onCreateView() { - loadGames(); - } - - public void refresh() { - Log.debug("[PlatformGamesPresenter] : Refreshing..."); - loadGames(); - } - - private void loadGames() { - Log.debug("[PlatformGamesPresenter] : Loading games..."); - - GameDatabase databaseHelper = CitraApplication.databaseHelper; - - databaseHelper.getGames() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(games -> - { - Log.debug("[PlatformGamesPresenter] : Load finished, swapping cursor..."); - - mView.showGames(games); - }); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java deleted file mode 100644 index 4332121eb..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.citra.citra_emu.ui.platform; - -import android.database.Cursor; - -/** - * Abstraction for a screen representing a single platform's games. - */ -public interface PlatformGamesView { - /** - * Tell the view to refresh its contents. - */ - void refresh(); - - /** - * To be called when an asynchronous database read completes. Passes the - * result, in this case a {@link Cursor}, to the view. - * - * @param games A Cursor containing the games read from the database. - */ - void showGames(Cursor games); -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java deleted file mode 100644 index 7578c353f..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.AsyncQueryHandler; -import android.content.ContentValues; -import android.content.Context; -import android.net.Uri; - -import org.citra.citra_emu.model.GameDatabase; -import org.citra.citra_emu.model.GameProvider; - -public class AddDirectoryHelper { - private Context mContext; - - public AddDirectoryHelper(Context context) { - this.mContext = context; - } - - public void addDirectory(String dir, AddDirectoryListener addDirectoryListener) { - AsyncQueryHandler handler = new AsyncQueryHandler(mContext.getContentResolver()) { - @Override - protected void onInsertComplete(int token, Object cookie, Uri uri) { - addDirectoryListener.onDirectoryAdded(); - } - }; - - ContentValues file = new ContentValues(); - file.put(GameDatabase.KEY_FOLDER_PATH, dir); - - handler.startInsert(0, // We don't need to identify this call to the handler - null, // We don't need to pass additional data to the handler - GameProvider.URI_FOLDER, // Tell the GameProvider we are adding a folder - file); - } - - public interface AddDirectoryListener { - void onDirectoryAdded(); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java deleted file mode 100644 index 5dc54c235..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java +++ /dev/null @@ -1,215 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.app.Activity; -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.widget.Toast; - -import com.android.billingclient.api.AcknowledgePurchaseParams; -import com.android.billingclient.api.AcknowledgePurchaseResponseListener; -import com.android.billingclient.api.BillingClient; -import com.android.billingclient.api.BillingClientStateListener; -import com.android.billingclient.api.BillingFlowParams; -import com.android.billingclient.api.BillingResult; -import com.android.billingclient.api.Purchase; -import com.android.billingclient.api.Purchase.PurchasesResult; -import com.android.billingclient.api.PurchasesUpdatedListener; -import com.android.billingclient.api.SkuDetails; -import com.android.billingclient.api.SkuDetailsParams; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.utils.SettingsFile; -import org.citra.citra_emu.ui.main.MainActivity; - -import java.util.ArrayList; -import java.util.List; - -public class BillingManager implements PurchasesUpdatedListener { - private final String BILLING_SKU_PREMIUM = "citra.citra_emu.product_id.premium"; - - private final Activity mActivity; - private BillingClient mBillingClient; - private SkuDetails mSkuPremium; - private boolean mIsPremiumActive = false; - private boolean mIsServiceConnected = false; - private Runnable mUpdateBillingCallback; - - private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); - - public BillingManager(Activity activity) { - mActivity = activity; - mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build(); - querySkuDetails(); - } - - static public boolean isPremiumCached() { - return mPreferences.getBoolean(SettingsFile.KEY_PREMIUM, false); - } - - /** - * @return true if Premium subscription is currently active - */ - public boolean isPremiumActive() { - return mIsPremiumActive; - } - - /** - * Invokes the billing flow for Premium - * - * @param callback Optional callback, called once, on completion of billing - */ - public void invokePremiumBilling(Runnable callback) { - if (mSkuPremium == null) { - return; - } - - // Optional callback to refresh the UI for the caller when billing completes - mUpdateBillingCallback = callback; - - // Invoke the billing flow - BillingFlowParams flowParams = BillingFlowParams.newBuilder() - .setSkuDetails(mSkuPremium) - .build(); - mBillingClient.launchBillingFlow(mActivity, flowParams); - } - - private void updatePremiumState(boolean isPremiumActive) { - mIsPremiumActive = isPremiumActive; - - // Cache state for synchronous UI - SharedPreferences.Editor editor = mPreferences.edit(); - editor.putBoolean(SettingsFile.KEY_PREMIUM, isPremiumActive); - editor.apply(); - - // No need to show button in action bar if Premium is active - MainActivity.setPremiumButtonVisible(!isPremiumActive); - } - - @Override - public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) { - if (purchaseList == null || purchaseList.isEmpty()) { - // Premium is not active, or billing is unavailable - updatePremiumState(false); - return; - } - - Purchase premiumPurchase = null; - for (Purchase purchase : purchaseList) { - if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) { - premiumPurchase = purchase; - } - } - - if (premiumPurchase != null && premiumPurchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED) { - // Premium has been purchased - updatePremiumState(true); - - // Acknowledge the purchase if it hasn't already been acknowledged. - if (!premiumPurchase.isAcknowledged()) { - AcknowledgePurchaseParams acknowledgePurchaseParams = - AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(premiumPurchase.getPurchaseToken()) - .build(); - - AcknowledgePurchaseResponseListener acknowledgePurchaseResponseListener = billingResult1 -> { - Toast.makeText(mActivity, R.string.premium_settings_welcome, Toast.LENGTH_SHORT).show(); - }; - mBillingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener); - } - - if (mUpdateBillingCallback != null) { - try { - mUpdateBillingCallback.run(); - } catch (Exception e) { - e.printStackTrace(); - } - mUpdateBillingCallback = null; - } - } - } - - private void onQuerySkuDetailsFinished(List skuDetailsList) { - if (skuDetailsList == null) { - // This can happen when no user is signed in - return; - } - - if (skuDetailsList.isEmpty()) { - return; - } - - mSkuPremium = skuDetailsList.get(0); - - queryPurchases(); - } - - private void querySkuDetails() { - Runnable queryToExecute = new Runnable() { - @Override - public void run() { - SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); - List skuList = new ArrayList<>(); - - skuList.add(BILLING_SKU_PREMIUM); - params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP); - - mBillingClient.querySkuDetailsAsync(params.build(), - (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList)); - } - }; - - executeServiceRequest(queryToExecute); - } - - private void onQueryPurchasesFinished(PurchasesResult result) { - // Have we been disposed of in the meantime? If so, or bad result code, then quit - if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) { - updatePremiumState(false); - return; - } - // Update the UI and purchases inventory with new list of purchases - onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList()); - } - - private void queryPurchases() { - Runnable queryToExecute = new Runnable() { - @Override - public void run() { - final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP); - onQueryPurchasesFinished(purchasesResult); - } - }; - - executeServiceRequest(queryToExecute); - } - - private void startServiceConnection(final Runnable executeOnFinish) { - mBillingClient.startConnection(new BillingClientStateListener() { - @Override - public void onBillingSetupFinished(BillingResult billingResult) { - if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { - mIsServiceConnected = true; - } - - if (executeOnFinish != null) { - executeOnFinish.run(); - } - } - - @Override - public void onBillingServiceDisconnected() { - mIsServiceConnected = false; - } - }); - } - - private void executeServiceRequest(Runnable runnable) { - if (mIsServiceConnected) { - runnable.run(); - } else { - // If billing service was disconnected, we try to reconnect 1 time. - startServiceConnection(runnable); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java index 2f7ca66c2..22f58ea4f 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CiaInstallWorker.java @@ -5,6 +5,7 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.widget.Toast; import androidx.annotation.NonNull; @@ -13,6 +14,7 @@ import androidx.work.ForegroundInfo; import androidx.work.Worker; import androidx.work.WorkerParameters; +import org.citra.citra_emu.NativeLibrary.InstallStatus; import org.citra.citra_emu.R; public class CiaInstallWorker extends Worker { @@ -56,15 +58,6 @@ public class CiaInstallWorker extends Worker { super(context, params); } - enum InstallStatus { - Success, - ErrorFailedToOpenFile, - ErrorFileNotFound, - ErrorAborted, - ErrorInvalid, - ErrorEncrypted, - } - private void notifyInstallStatus(String filename, InstallStatus status) { switch(status){ case Success: @@ -126,10 +119,10 @@ public class CiaInstallWorker extends Worker { int i = 0; for (String file : selectedFiles) { - String filename = FileUtil.getFilename(mContext, file); + String filename = FileUtil.getFilename(Uri.parse(file)); mInstallProgressBuilder.setContentText(mContext.getString( R.string.cia_install_notification_installing, filename, ++i, selectedFiles.length)); - InstallStatus res = InstallCIA(file); + InstallStatus res = installCIA(file); notifyInstallStatus(filename, res); } mNotificationManager.cancel(PROGRESS_NOTIFICATION_ID); @@ -156,5 +149,5 @@ public class CiaInstallWorker extends Worker { return new ForegroundInfo(PROGRESS_NOTIFICATION_ID, mInstallProgressBuilder.build()); } - private native InstallStatus InstallCIA(String path); + private native InstallStatus installCIA(String path); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java deleted file mode 100644 index 5a3ff6119..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.java +++ /dev/null @@ -1,87 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.Intent; -import android.net.Uri; -import androidx.fragment.app.FragmentActivity; -import java.util.concurrent.Executors; -import org.citra.citra_emu.dialogs.CitraDirectoryDialog; -import org.citra.citra_emu.dialogs.CopyDirProgressDialog; - -/** - * Citra directory initialization ui flow controller. - */ -public class CitraDirectoryHelper { - public interface Listener { - void onDirectoryInitialized(); - } - - private final FragmentActivity mFragmentActivity; - private final Listener mListener; - - public CitraDirectoryHelper(FragmentActivity mFragmentActivity, Listener mListener) { - this.mFragmentActivity = mFragmentActivity; - this.mListener = mListener; - } - - public void showCitraDirectoryDialog(Uri result) { - CitraDirectoryDialog citraDirectoryDialog = CitraDirectoryDialog.newInstance( - result.toString(), ((moveData, path) -> { - Uri previous = PermissionsHandler.getCitraDirectory(); - // Do noting if user select the previous path. - if (path.equals(previous)) { - return; - } - int takeFlags = (Intent.FLAG_GRANT_WRITE_URI_PERMISSION | - Intent.FLAG_GRANT_READ_URI_PERMISSION); - mFragmentActivity.getContentResolver().takePersistableUriPermission(path, - takeFlags); - if (!moveData || previous == null) { - initializeCitraDirectory(path); - mListener.onDirectoryInitialized(); - return; - } - - // If user check move data, show copy progress dialog. - showCopyDialog(previous, path); - })); - citraDirectoryDialog.show(mFragmentActivity.getSupportFragmentManager(), - CitraDirectoryDialog.TAG); - } - - private void showCopyDialog(Uri previous, Uri path) { - CopyDirProgressDialog copyDirProgressDialog = new CopyDirProgressDialog(); - copyDirProgressDialog.showNow(mFragmentActivity.getSupportFragmentManager(), - CopyDirProgressDialog.TAG); - - // Run copy dir in background - Executors.newSingleThreadExecutor().execute(() -> { - FileUtil.copyDir( - mFragmentActivity, previous.toString(), path.toString(), - new FileUtil.CopyDirListener() { - @Override - public void onSearchProgress(String directoryName) { - copyDirProgressDialog.onUpdateSearchProgress(directoryName); - } - - @Override - public void onCopyProgress(String filename, int progress, int max) { - copyDirProgressDialog.onUpdateCopyProgress(filename, progress, max); - } - - @Override - public void onComplete() { - initializeCitraDirectory(path); - copyDirProgressDialog.dismissAllowingStateLoss(); - mListener.onDirectoryInitialized(); - } - }); - }); - } - - private void initializeCitraDirectory(Uri path) { - if (!PermissionsHandler.setCitraDirectory(path.toString())) - return; - DirectoryInitialization.resetCitraDirectoryState(); - DirectoryInitialization.start(mFragmentActivity); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt new file mode 100644 index 000000000..0b6c91a98 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/CitraDirectoryHelper.kt @@ -0,0 +1,63 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.Intent +import android.net.Uri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModelProvider +import org.citra.citra_emu.fragments.CitraDirectoryDialogFragment +import org.citra.citra_emu.fragments.CopyDirProgressDialog +import org.citra.citra_emu.model.SetupCallback +import org.citra.citra_emu.viewmodel.HomeViewModel + +/** + * Citra directory initialization ui flow controller. + */ +class CitraDirectoryHelper(private val fragmentActivity: FragmentActivity) { + fun showCitraDirectoryDialog(result: Uri, callback: SetupCallback? = null) { + val citraDirectoryDialog = CitraDirectoryDialogFragment.newInstance( + fragmentActivity, + result.toString(), + CitraDirectoryDialogFragment.Listener { moveData: Boolean, path: Uri -> + val previous = PermissionsHandler.citraDirectory + // Do noting if user select the previous path. + if (path == previous) { + return@Listener + } + + val takeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_READ_URI_PERMISSION + fragmentActivity.contentResolver.takePersistableUriPermission( + path, + takeFlags + ) + if (!moveData || previous.toString().isEmpty()) { + initializeCitraDirectory(path) + callback?.onStepCompleted() + val viewModel = ViewModelProvider(fragmentActivity)[HomeViewModel::class.java] + viewModel.setUserDir(fragmentActivity, path.path!!) + viewModel.setPickingUserDir(false) + return@Listener + } + + // If user check move data, show copy progress dialog. + CopyDirProgressDialog.newInstance(fragmentActivity, previous, path, callback) + ?.show(fragmentActivity.supportFragmentManager, CopyDirProgressDialog.TAG) + }) + citraDirectoryDialog.show( + fragmentActivity.supportFragmentManager, + CitraDirectoryDialogFragment.TAG + ) + } + + companion object { + fun initializeCitraDirectory(path: Uri) { + PermissionsHandler.setCitraDirectory(path.toString()) + DirectoryInitialization.resetCitraDirectoryState() + DirectoryInitialization.start() + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java deleted file mode 100644 index 5de5d9a74..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Copyright 2014 Dolphin Emulator Project - * Licensed under GPLv2+ - * Refer to the license.txt file included. - */ - -package org.citra.citra_emu.utils; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.os.Environment; -import android.preference.PreferenceManager; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.concurrent.atomic.AtomicBoolean; -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.NativeLibrary; - -/** - * A service that spawns its own thread in order to copy several binary and shader files - * from the Citra APK to the external file system. - */ -public final class DirectoryInitialization { - public static final String BROADCAST_ACTION = "org.citra.citra_emu.BROADCAST"; - - public static final String EXTRA_STATE = "directoryState"; - private static volatile DirectoryInitializationState directoryState = null; - private static String userPath; - private static AtomicBoolean isCitraDirectoryInitializationRunning = new AtomicBoolean(false); - - public static void start(Context context) { - // Can take a few seconds to run, so don't block UI thread. - //noinspection TrivialFunctionalExpressionUsage - ((Runnable) () -> init(context)).run(); - } - - private static void init(Context context) { - if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true)) - return; - - if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { - if (PermissionsHandler.hasWriteAccess(context)) { - if (setCitraUserDirectory()) { - initializeInternalStorage(context); - CitraApplication.documentsTree.setRoot(Uri.parse(userPath)); - NativeLibrary.CreateLogFile(); - NativeLibrary.LogUserDirectory(userPath); - NativeLibrary.CreateConfigFile(); - directoryState = DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; - } else { - directoryState = DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE; - } - } else { - directoryState = DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED; - } - } - - isCitraDirectoryInitializationRunning.set(false); - sendBroadcastState(directoryState, context); - } - - private static void deleteDirectoryRecursively(File file) { - if (file.isDirectory()) { - for (File child : file.listFiles()) - deleteDirectoryRecursively(child); - } - file.delete(); - } - - public static boolean areCitraDirectoriesReady() { - return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED; - } - - public static void resetCitraDirectoryState() { - directoryState = null; - isCitraDirectoryInitializationRunning.compareAndSet(true, false); - } - - public static String getUserDirectory() { - if (directoryState == null) { - throw new IllegalStateException("DirectoryInitialization has to run at least once!"); - } else if (isCitraDirectoryInitializationRunning.get()) { - throw new IllegalStateException( - "DirectoryInitialization has to finish running first!"); - } - return userPath; - } - - private static native void SetSysDirectory(String path); - - private static boolean setCitraUserDirectory() { - Uri dataPath = PermissionsHandler.getCitraDirectory(); - if (dataPath != null) { - userPath = dataPath.toString(); - Log.debug("[DirectoryInitialization] User Dir: " + userPath); - return true; - } - - return false; - } - - private static void initializeInternalStorage(Context context) { - File sysDirectory = new File(context.getFilesDir(), "Sys"); - - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); - String revision = NativeLibrary.GetGitRevision(); - if (!preferences.getString("sysDirectoryVersion", "").equals(revision)) { - // There is no extracted Sys directory, or there is a Sys directory from another - // version of Citra that might contain outdated files. Let's (re-)extract Sys. - deleteDirectoryRecursively(sysDirectory); - copyAssetFolder("Sys", sysDirectory, true, context); - - SharedPreferences.Editor editor = preferences.edit(); - editor.putString("sysDirectoryVersion", revision); - editor.apply(); - } - - // Let the native code know where the Sys directory is. - SetSysDirectory(sysDirectory.getPath()); - } - - private static void sendBroadcastState(DirectoryInitializationState state, Context context) { - Intent localIntent = - new Intent(BROADCAST_ACTION) - .putExtra(EXTRA_STATE, state); - LocalBroadcastManager.getInstance(context).sendBroadcast(localIntent); - } - - private static void copyAsset(String asset, File output, Boolean overwrite, Context context) { - Log.verbose("[DirectoryInitialization] Copying File " + asset + " to " + output); - - try { - if (!output.exists() || overwrite) { - InputStream in = context.getAssets().open(asset); - OutputStream out = new FileOutputStream(output); - copyFile(in, out); - in.close(); - out.close(); - } - } catch (IOException e) { - Log.error("[DirectoryInitialization] Failed to copy asset file: " + asset + - e.getMessage()); - } - } - - private static void copyAssetFolder(String assetFolder, File outputFolder, Boolean overwrite, - Context context) { - Log.verbose("[DirectoryInitialization] Copying Folder " + assetFolder + " to " + - outputFolder); - - try { - boolean createdFolder = false; - for (String file : context.getAssets().list(assetFolder)) { - if (!createdFolder) { - outputFolder.mkdir(); - createdFolder = true; - } - copyAssetFolder(assetFolder + File.separator + file, new File(outputFolder, file), - overwrite, context); - copyAsset(assetFolder + File.separator + file, new File(outputFolder, file), overwrite, - context); - } - } catch (IOException e) { - Log.error("[DirectoryInitialization] Failed to copy asset folder: " + assetFolder + - e.getMessage()); - } - } - - private static void copyFile(InputStream in, OutputStream out) throws IOException { - byte[] buffer = new byte[1024]; - int read; - - while ((read = in.read(buffer)) != -1) { - out.write(buffer, 0, read); - } - } - - public enum DirectoryInitializationState { - CITRA_DIRECTORIES_INITIALIZED, - EXTERNAL_STORAGE_PERMISSION_NEEDED, - CANT_FIND_EXTERNAL_STORAGE - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt new file mode 100644 index 000000000..10e509f23 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.kt @@ -0,0 +1,163 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.Context +import android.net.Uri +import androidx.preference.PreferenceManager +import org.citra.citra_emu.BuildConfig +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.utils.PermissionsHandler.hasWriteAccess +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A service that spawns its own thread in order to copy several binary and shader files + * from the Citra APK to the external file system. + */ +object DirectoryInitialization { + private const val SYS_DIR_VERSION = "sysDirectoryVersion" + + @Volatile + private var directoryState: DirectoryInitializationState? = null + var userPath: String? = null + val internalUserPath + get() = CitraApplication.appContext.getExternalFilesDir(null)!!.canonicalPath + private val isCitraDirectoryInitializationRunning = AtomicBoolean(false) + + val context: Context get() = CitraApplication.appContext + + @JvmStatic + fun start(): DirectoryInitializationState? { + if (!isCitraDirectoryInitializationRunning.compareAndSet(false, true)) { + return null + } + + if (directoryState != DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + directoryState = if (hasWriteAccess(context)) { + if (setCitraUserDirectory()) { + CitraApplication.documentsTree.setRoot(Uri.parse(userPath)) + NativeLibrary.createLogFile() + NativeLibrary.logUserDirectory(userPath.toString()) + NativeLibrary.createConfigFile() + GpuDriverHelper.initializeDriverParameters() + DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED + } else { + DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE + } + } else { + DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED + } + } + isCitraDirectoryInitializationRunning.set(false) + return directoryState + } + + private fun deleteDirectoryRecursively(file: File) { + if (file.isDirectory) { + for (child in file.listFiles()!!) { + deleteDirectoryRecursively(child) + } + } + file.delete() + } + + @JvmStatic + fun areCitraDirectoriesReady(): Boolean { + return directoryState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED + } + + fun resetCitraDirectoryState() { + directoryState = null + isCitraDirectoryInitializationRunning.compareAndSet(true, false) + } + + val userDirectory: String? + get() { + checkNotNull(directoryState) { + "DirectoryInitialization has to run at least once!" + } + check(!isCitraDirectoryInitializationRunning.get()) { + "DirectoryInitialization has to finish running first!" + } + return userPath + } + + fun setCitraUserDirectory(): Boolean { + val dataPath = PermissionsHandler.citraDirectory + if (dataPath.toString().isNotEmpty()) { + userPath = dataPath.toString() + Log.debug("[DirectoryInitialization] User Dir: $userPath") + return true + } + return false + } + + private fun copyAsset(asset: String, output: File, overwrite: Boolean, context: Context) { + Log.verbose("[DirectoryInitialization] Copying File $asset to $output") + try { + if (!output.exists() || overwrite) { + val inputStream = context.assets.open(asset) + val outputStream = FileOutputStream(output) + copyFile(inputStream, outputStream) + inputStream.close() + outputStream.close() + } + } catch (e: IOException) { + Log.error("[DirectoryInitialization] Failed to copy asset file: $asset" + e.message) + } + } + + private fun copyAssetFolder( + assetFolder: String, + outputFolder: File, + overwrite: Boolean, + context: Context + ) { + Log.verbose("[DirectoryInitialization] Copying Folder $assetFolder to $outputFolder") + try { + var createdFolder = false + for (file in context.assets.list(assetFolder)!!) { + if (!createdFolder) { + outputFolder.mkdir() + createdFolder = true + } + copyAssetFolder( + assetFolder + File.separator + file, File(outputFolder, file), + overwrite, context + ) + copyAsset( + assetFolder + File.separator + file, File(outputFolder, file), overwrite, + context + ) + } + } catch (e: IOException) { + Log.error( + "[DirectoryInitialization] Failed to copy asset folder: $assetFolder" + + e.message + ) + } + } + + @Throws(IOException::class) + private fun copyFile(inputStream: InputStream, outputStream: OutputStream) { + val buffer = ByteArray(1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + } + } + + enum class DirectoryInitializationState { + CITRA_DIRECTORIES_INITIALIZED, + EXTERNAL_STORAGE_PERMISSION_NEEDED, + CANT_FIND_EXTERNAL_STORAGE + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java deleted file mode 100644 index 5d1e951ca..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; - -public class DirectoryStateReceiver extends BroadcastReceiver { - Action1 callback; - - public DirectoryStateReceiver(Action1 callback) { - this.callback = callback; - } - - @Override - public void onReceive(Context context, Intent intent) { - DirectoryInitializationState state = (DirectoryInitializationState) intent - .getSerializableExtra(DirectoryInitialization.EXTRA_STATE); - callback.call(state); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java index 527875249..5897328ae 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DiskShaderCacheProgress.java @@ -12,6 +12,7 @@ import android.view.View; import android.widget.ProgressBar; import android.widget.TextView; +import androidx.annotation.Keep; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; @@ -25,6 +26,7 @@ import org.citra.citra_emu.utils.Log; import java.util.Objects; +@Keep public class DiskShaderCacheProgress { // Equivalent to VideoCore::LoadCallbackStage diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java deleted file mode 100644 index 7cf030748..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.java +++ /dev/null @@ -1,300 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.Context; -import android.net.Uri; -import android.provider.DocumentsContract; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.model.CheapDocument; - -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URLDecoder; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.StringTokenizer; - -/** - * A cached document tree for citra user directory. - * For every filepath which is not startsWith "content://" will need to use this class to traverse. - * For example: - * C++ citra log file directory will be /log/citra_log.txt. - * After DocumentsTree.resolvePath() it will become content URI. - */ -public class DocumentsTree { - private DocumentsNode root; - private final Context context; - public static final String DELIMITER = "/"; - - public DocumentsTree() { - context = CitraApplication.getAppContext(); - } - - public void setRoot(Uri rootUri) { - root = null; - root = new DocumentsNode(); - root.uri = rootUri; - root.isDirectory = true; - } - - public boolean createFile(String filepath, String name) { - DocumentsNode node = resolvePath(filepath); - if (node == null) return false; - if (!node.isDirectory) return false; - if (!node.loaded) structTree(node); - Uri mUri = node.uri; - try { - String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); - if (node.findChild(filename) != null) return true; - DocumentFile createdFile = FileUtil.createFile(context, mUri.toString(), name); - if (createdFile == null) return false; - DocumentsNode document = new DocumentsNode(createdFile, false); - document.parent = node; - node.addChild(document); - return true; - } catch (Exception e) { - Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); - } - return false; - } - - public boolean createDir(String filepath, String name) { - DocumentsNode node = resolvePath(filepath); - if (node == null) return false; - if (!node.isDirectory) return false; - if (!node.loaded) structTree(node); - Uri mUri = node.uri; - try { - String filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD); - if (node.findChild(filename) != null) return true; - DocumentFile createdDirectory = FileUtil.createDir(context, mUri.toString(), name); - if (createdDirectory == null) return false; - DocumentsNode document = new DocumentsNode(createdDirectory, true); - document.parent = node; - node.addChild(document); - return true; - } catch (Exception e) { - Log.error("[DocumentsTree]: Cannot create file, error: " + e.getMessage()); - } - return false; - } - - public int openContentUri(String filepath, String openmode) { - DocumentsNode node = resolvePath(filepath); - if (node == null) { - return -1; - } - return FileUtil.openContentUri(context, node.uri.toString(), openmode); - } - - public String getFilename(String filepath) { - DocumentsNode node = resolvePath(filepath); - if (node == null) { - return ""; - } - return node.name; - } - - public String[] getFilesName(String filepath) { - DocumentsNode node = resolvePath(filepath); - if (node == null || !node.isDirectory) { - return new String[0]; - } - // If this directory have not been iterate struct it. - if (!node.loaded) structTree(node); - return node.getChildNames(); - } - - public long getFileSize(String filepath) { - DocumentsNode node = resolvePath(filepath); - if (node == null || node.isDirectory) { - return 0; - } - return FileUtil.getFileSize(context, node.uri.toString()); - } - - public boolean isDirectory(String filepath) { - DocumentsNode node = resolvePath(filepath); - if (node == null) return false; - return node.isDirectory; - } - - public boolean Exists(String filepath) { - return resolvePath(filepath) != null; - } - - public boolean copyFile(String sourcePath, String destinationParentPath, String destinationFilename) { - DocumentsNode sourceNode = resolvePath(sourcePath); - if (sourceNode == null) return false; - DocumentsNode destinationNode = resolvePath(destinationParentPath); - if (destinationNode == null) return false; - try { - DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationNode.uri); - if (destinationParent == null) return false; - String filename = URLDecoder.decode(destinationFilename, "UTF-8"); - DocumentFile destination = destinationParent.createFile("application/octet-stream", filename); - if (destination == null) return false; - DocumentsNode document = new DocumentsNode(); - document.uri = destination.getUri(); - document.parent = destinationNode; - document.name = destination.getName(); - document.isDirectory = destination.isDirectory(); - document.loaded = true; - InputStream input = context.getContentResolver().openInputStream(sourceNode.uri); - OutputStream output = context.getContentResolver().openOutputStream(destination.getUri(), "wt"); - byte[] buffer = new byte[1024]; - int len; - while ((len = input.read(buffer)) != -1) { - output.write(buffer, 0, len); - } - input.close(); - output.flush(); - output.close(); - destinationNode.addChild(document); - return true; - } catch (Exception e) { - Log.error("[DocumentsTree]: Cannot copy file, error: " + e.getMessage()); - } - return false; - } - - public boolean renameFile(String filepath, String destinationFilename) { - DocumentsNode node = resolvePath(filepath); - if (node == null) return false; - try { - Uri mUri = node.uri; - String filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD); - DocumentsContract.renameDocument(context.getContentResolver(), mUri, filename); - node.rename(filename); - return true; - } catch (Exception e) { - Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage()); - } - return false; - } - - public boolean deleteDocument(String filepath) { - DocumentsNode node = resolvePath(filepath); - if (node == null) return false; - try { - Uri mUri = node.uri; - if (!DocumentsContract.deleteDocument(context.getContentResolver(), mUri)) { - return false; - } - if (node.parent != null) { - node.parent.removeChild(node); - } - return true; - } catch (Exception e) { - Log.error("[DocumentsTree]: Cannot rename file, error: " + e.getMessage()); - } - return false; - } - - @Nullable - private DocumentsNode resolvePath(String filepath) { - if (root == null) - return null; - StringTokenizer tokens = new StringTokenizer(filepath, DELIMITER, false); - DocumentsNode iterator = root; - while (tokens.hasMoreTokens()) { - String token = tokens.nextToken(); - if (token.isEmpty()) continue; - iterator = find(iterator, token); - if (iterator == null) return null; - } - return iterator; - } - - @Nullable - private DocumentsNode find(DocumentsNode parent, String filename) { - if (parent.isDirectory && !parent.loaded) { - structTree(parent); - } - return parent.findChild(filename); - } - - /** - * Construct current level directory tree - * - * @param parent parent node of this level - */ - private void structTree(DocumentsNode parent) { - CheapDocument[] documents = FileUtil.listFiles(context, parent.uri); - for (CheapDocument document : documents) { - DocumentsNode node = new DocumentsNode(document); - node.parent = parent; - parent.addChild(node); - } - parent.loaded = true; - } - - @NonNull - private static String toLowerCase(@NonNull String str) { - return str.toLowerCase(Locale.ROOT); - } - - private static class DocumentsNode { - private DocumentsNode parent; - private final Map children = new HashMap<>(); - private String name; - private Uri uri; - private boolean loaded = false; - private boolean isDirectory = false; - - private DocumentsNode() {} - - private DocumentsNode(CheapDocument document) { - name = document.getFilename(); - uri = document.getUri(); - isDirectory = document.isDirectory(); - loaded = !isDirectory; - } - - private DocumentsNode(DocumentFile document, boolean isCreateDir) { - name = document.getName(); - uri = document.getUri(); - isDirectory = isCreateDir; - loaded = true; - } - - private void rename(String name) { - if (parent == null) { - return; - } - parent.removeChild(this); - this.name = name; - parent.addChild(this); - } - - private void addChild(DocumentsNode node) { - children.put(toLowerCase(node.name), node); - } - - private void removeChild(DocumentsNode node) { - children.remove(toLowerCase(node.name)); - } - - @Nullable - private DocumentsNode findChild(String filename) { - return children.get(toLowerCase(filename)); - } - - @NonNull - private String[] getChildNames() { - String[] names = new String[children.size()]; - - int i = 0; - for (DocumentsNode child : children.values()) { - names[i++] = child.name; - } - - return names; - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt new file mode 100644 index 000000000..f2512f148 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/DocumentsTree.kt @@ -0,0 +1,275 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.model.CheapDocument +import java.net.URLDecoder +import java.util.StringTokenizer +import java.util.concurrent.ConcurrentHashMap + +/** + * A cached document tree for Citra user directory. + * For every filepath which is not startsWith "content://" will need to use this class to traverse. + * For example: + * C++ Citra log file directory will be /log/citra_log.txt. + * After DocumentsTree.resolvePath() it will become content URI. + */ +class DocumentsTree { + private var root: DocumentsNode? = null + private val context get() = CitraApplication.appContext + + fun setRoot(rootUri: Uri?) { + root = null + root = DocumentsNode() + root!!.uri = rootUri + root!!.isDirectory = true + } + + @Synchronized + fun createFile(filepath: String, name: String): Boolean { + val node = resolvePath(filepath) ?: return false + if (!node.isDirectory) return false + if (!node.loaded) structTree(node) + try { + val filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD) + if (node.findChild(filename) != null) return true + val createdFile = FileUtil.createFile(node.uri.toString(), name) ?: return false + val document = DocumentsNode(createdFile, false) + document.parent = node + node.addChild(document) + return true + } catch (e: Exception) { + error("[DocumentsTree]: Cannot create file, error: " + e.message) + } + } + + @Synchronized + fun createDir(filepath: String, name: String): Boolean { + val node = resolvePath(filepath) ?: return false + if (!node.isDirectory) return false + if (!node.loaded) structTree(node) + try { + val filename = URLDecoder.decode(name, FileUtil.DECODE_METHOD) + if (node.findChild(filename) != null) return true + val createdDirectory = FileUtil.createDir(node.uri.toString(), name) ?: return false + val document = DocumentsNode(createdDirectory, true) + document.parent = node + node.addChild(document) + return true + } catch (e: Exception) { + error("[DocumentsTree]: Cannot create file, error: " + e.message) + } + } + + @Synchronized + fun openContentUri(filepath: String, openMode: String): Int { + val node = resolvePath(filepath) ?: return -1 + return FileUtil.openContentUri(node.uri.toString(), openMode) + } + + @Synchronized + fun getFilename(filepath: String): String { + val node = resolvePath(filepath) ?: return "" + return node.name + } + + @Synchronized + fun getFilesName(filepath: String): Array { + val node = resolvePath(filepath) + if (node == null || !node.isDirectory) { + return arrayOfNulls(0) + } + // If this directory has not been iterated, struct it. + if (!node.loaded) structTree(node) + return node.getChildNames() + } + + @Synchronized + fun getFileSize(filepath: String): Long { + val node = resolvePath(filepath) + return if (node == null || node.isDirectory) { + 0 + } else { + FileUtil.getFileSize(node.uri.toString()) + } + } + + @Synchronized + fun isDirectory(filepath: String): Boolean { + val node = resolvePath(filepath) ?: return false + return node.isDirectory + } + + @Synchronized + fun exists(filepath: String): Boolean { + return resolvePath(filepath) != null + } + + @Synchronized + fun copyFile( + sourcePath: String, + destinationParentPath: String, + destinationFilename: String + ): Boolean { + val sourceNode = resolvePath(sourcePath) ?: return false + val destinationNode = resolvePath(destinationParentPath) ?: return false + try { + val destinationParent = + DocumentFile.fromTreeUri(context, destinationNode.uri!!) ?: return false + val filename = URLDecoder.decode(destinationFilename, "UTF-8") + val destination = destinationParent.createFile( + "application/octet-stream", + filename + ) ?: return false + val document = DocumentsNode() + document.uri = destination.uri + document.parent = destinationNode + document.name = destination.name!! + document.isDirectory = destination.isDirectory + document.loaded = true + val input = context.contentResolver.openInputStream(sourceNode.uri!!)!! + val output = context.contentResolver.openOutputStream(destination.uri, "wt")!! + val buffer = ByteArray(1024) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + output.write(buffer, 0, len) + } + input.close() + output.flush() + output.close() + destinationNode.addChild(document) + return true + } catch (e: Exception) { + error("[DocumentsTree]: Cannot copy file, error: " + e.message) + } + } + + @Synchronized + fun renameFile(filepath: String, destinationFilename: String?): Boolean { + val node = resolvePath(filepath) ?: return false + try { + val filename = URLDecoder.decode(destinationFilename, FileUtil.DECODE_METHOD) + DocumentsContract.renameDocument(context.contentResolver, node.uri!!, filename) + node.rename(filename) + return true + } catch (e: Exception) { + error("[DocumentsTree]: Cannot rename file, error: " + e.message) + } + } + + @Synchronized + fun deleteDocument(filepath: String): Boolean { + val node = resolvePath(filepath) ?: return false + try { + if (!DocumentsContract.deleteDocument(context.contentResolver, node.uri!!)) { + return false + } + if (node.parent != null) { + node.parent!!.removeChild(node) + } + return true + } catch (e: Exception) { + error("[DocumentsTree]: Cannot rename file, error: " + e.message) + } + } + + @Synchronized + private fun resolvePath(filepath: String): DocumentsNode? { + root ?: return null + val tokens = StringTokenizer(filepath, DELIMITER, 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 + } + + @Synchronized + private fun find(parent: DocumentsNode, filename: String): DocumentsNode? { + if (parent.isDirectory && !parent.loaded) { + structTree(parent) + } + return parent.findChild(filename) + } + + /** + * Construct current level directory tree + * + * @param parent parent node of this level + */ + @Synchronized + private fun structTree(parent: DocumentsNode) { + val documents = FileUtil.listFiles(parent.uri!!) + for (document in documents) { + val node = DocumentsNode(document) + node.parent = parent + parent.addChild(node) + } + parent.loaded = true + } + + private class DocumentsNode { + @get:Synchronized + @set:Synchronized + var parent: DocumentsNode? = null + val children: MutableMap = ConcurrentHashMap() + lateinit var name: String + + @get:Synchronized + @set:Synchronized + var uri: Uri? = null + + @get:Synchronized + @set:Synchronized + var loaded = false + var isDirectory = false + + constructor() + constructor(document: CheapDocument) { + name = document.filename + uri = document.uri + isDirectory = document.isDirectory + loaded = !isDirectory + } + + constructor(document: DocumentFile, isCreateDir: Boolean) { + name = document.name!! + uri = document.uri + isDirectory = isCreateDir + loaded = true + } + + @Synchronized + fun rename(name: String) { + parent ?: return + parent!!.removeChild(this) + this.name = name + parent!!.addChild(this) + } + + fun addChild(node: DocumentsNode) { + children[node.name.lowercase()] = node + } + + fun removeChild(node: DocumentsNode) = children.remove(node.name.lowercase()) + + fun findChild(filename: String) = children[filename.lowercase()] + + @Synchronized + fun getChildNames(): Array = + children.mapNotNull { it.value!!.name }.toTypedArray() + } + + companion object { + const val DELIMITER = "/" + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java index 0dc2d764d..2b31876b6 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java @@ -6,7 +6,7 @@ import android.preference.PreferenceManager; import org.citra.citra_emu.CitraApplication; public class EmulationMenuSettings { - private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.Companion.getAppContext()); // These must match what is defined in src/common/settings.h public static final int LayoutOption_Default = 0; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java deleted file mode 100644 index 6eb9de33e..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java +++ /dev/null @@ -1,454 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.ContentResolver; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.DocumentsContract; -import android.system.Os; -import android.system.StructStatVfs; -import android.util.Pair; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.net.URLDecoder; -import java.util.ArrayList; -import java.util.List; -import org.citra.citra_emu.model.CheapDocument; - -public class FileUtil { - static final String PATH_TREE = "tree"; - static final String DECODE_METHOD = "UTF-8"; - static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; - static final String TEXT_PLAIN = "text/plain"; - - public interface CopyDirListener { - void onSearchProgress(String directoryName); - void onCopyProgress(String filename, int progress, int max); - - void onComplete(); - } - - /** - * Create a file from directory with filename. - * - * @param context Application context - * @param directory parent path for file. - * @param filename file display name. - * @return boolean - */ - @Nullable - public static DocumentFile createFile(Context context, String directory, String filename) { - try { - Uri directoryUri = Uri.parse(directory); - DocumentFile parent; - parent = DocumentFile.fromTreeUri(context, directoryUri); - if (parent == null) return null; - filename = URLDecoder.decode(filename, DECODE_METHOD); - int extensionPosition = filename.lastIndexOf('.'); - String extension = ""; - if (extensionPosition > 0) { - extension = filename.substring(extensionPosition); - } - String mimeType = APPLICATION_OCTET_STREAM; - if (extension.equals(".txt")) { - mimeType = TEXT_PLAIN; - } - DocumentFile isExist = parent.findFile(filename); - if (isExist != null) return isExist; - return parent.createFile(mimeType, filename); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); - } - 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 - */ - @Nullable - public static DocumentFile createDir(Context context, String directory, String directoryName) { - try { - Uri directoryUri = Uri.parse(directory); - DocumentFile parent; - parent = DocumentFile.fromTreeUri(context, directoryUri); - if (parent == null) return null; - directoryName = URLDecoder.decode(directoryName, DECODE_METHOD); - DocumentFile isExist = parent.findFile(directoryName); - if (isExist != null) return isExist; - return parent.createDirectory(directoryName); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot create file, error: " + e.getMessage()); - } - 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 - */ - public static int openContentUri(Context context, String path, String openmode) { - try (ParcelFileDescriptor parcelFileDescriptor = - context.getContentResolver().openFileDescriptor(Uri.parse(path), openmode)) { - if (parcelFileDescriptor == null) { - Log.error("[FileUtil]: Cannot get the file descriptor from uri: " + path); - return -1; - } - return parcelFileDescriptor.detachFd(); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot open content uri, error: " + e.getMessage()); - } - return -1; - } - - /** - * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow - * This function will be faster than DocumentFile.listFiles - * - * @param context Application context - * @param uri Directory uri. - * @return CheapDocument lists. - */ - public static CheapDocument[] listFiles(Context context, Uri uri) { - final ContentResolver resolver = context.getContentResolver(); - final String[] columns = new String[]{ - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE, - }; - Cursor c = null; - final List results = new ArrayList<>(); - try { - String docId; - if (isRootTreeUri(uri)) { - docId = DocumentsContract.getTreeDocumentId(uri); - } else { - docId = DocumentsContract.getDocumentId(uri); - } - final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId); - c = resolver.query(childrenUri, columns, null, null, null); - while (c.moveToNext()) { - final String documentId = c.getString(0); - final String documentName = c.getString(1); - final String documentMimeType = c.getString(2); - final Uri documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); - CheapDocument document = new CheapDocument(documentName, documentMimeType, documentUri); - results.add(document); - } - } catch (Exception e) { - Log.error("[FileUtil]: Cannot list file error: " + e.getMessage()); - } finally { - closeQuietly(c); - } - return results.toArray(new CheapDocument[0]); - } - - /** - * Check whether given path exists. - * - * @param path Native content uri path - * @return bool - */ - public static boolean Exists(Context context, String path) { - Cursor c = null; - try { - Uri mUri = Uri.parse(path); - final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DOCUMENT_ID}; - c = context.getContentResolver().query(mUri, columns, null, null, null); - return c.getCount() > 0; - } catch (Exception e) { - Log.info("[FileUtil] Cannot find file from given path, error: " + e.getMessage()); - } finally { - closeQuietly(c); - } - return false; - } - - /** - * Check whether given path is a directory - * - * @param path content uri path - * @return bool - */ - public static boolean isDirectory(Context context, String path) { - final ContentResolver resolver = context.getContentResolver(); - final String[] columns = new String[] {DocumentsContract.Document.COLUMN_MIME_TYPE}; - boolean isDirectory = false; - Cursor c = null; - try { - Uri mUri = Uri.parse(path); - c = resolver.query(mUri, columns, null, null, null); - c.moveToNext(); - final String mimeType = c.getString(0); - isDirectory = mimeType.equals(DocumentsContract.Document.MIME_TYPE_DIR); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot list files, error: " + e.getMessage()); - } finally { - closeQuietly(c); - } - return isDirectory; - } - - /** - * Get file display name from given path - * - * @param path content uri path - * @return String display name - */ - public static String getFilename(Context context, String path) { - final ContentResolver resolver = context.getContentResolver(); - final String[] columns = new String[] {DocumentsContract.Document.COLUMN_DISPLAY_NAME}; - String filename = ""; - Cursor c = null; - try { - Uri mUri = Uri.parse(path); - c = resolver.query(mUri, columns, null, null, null); - c.moveToNext(); - filename = c.getString(0); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); - } finally { - closeQuietly(c); - } - return filename; - } - - public static String[] getFilesName(Context context, String path) { - Uri uri = Uri.parse(path); - List files = new ArrayList<>(); - for (CheapDocument file : FileUtil.listFiles(context, uri)) { - files.add(file.getFilename()); - } - return files.toArray(new String[0]); - } - - /** - * Get file size from given path. - * - * @param path content uri path - * @return long file size - */ - public static long getFileSize(Context context, String path) { - final ContentResolver resolver = context.getContentResolver(); - final String[] columns = new String[] {DocumentsContract.Document.COLUMN_SIZE}; - long size = 0; - Cursor c = null; - try { - Uri mUri = Uri.parse(path); - c = resolver.query(mUri, columns, null, null, null); - c.moveToNext(); - size = c.getLong(0); - } catch (Exception e) { - Log.error("[FileUtil]: Cannot get file size, error: " + e.getMessage()); - } finally { - closeQuietly(c); - } - return size; - } - - public static boolean copyFile(Context context, String sourcePath, String destinationParentPath, String destinationFilename) { - try { - Uri sourceUri = Uri.parse(sourcePath); - Uri destinationUri = Uri.parse(destinationParentPath); - DocumentFile destinationParent = DocumentFile.fromTreeUri(context, destinationUri); - if (destinationParent == null) return false; - String filename = URLDecoder.decode(destinationFilename, "UTF-8"); - DocumentFile destination = destinationParent.findFile(filename); - if (destination == null) { - destination = destinationParent.createFile("application/octet-stream", filename); - } - if (destination == null) return false; - InputStream input = context.getContentResolver().openInputStream(sourceUri); - OutputStream output = context.getContentResolver().openOutputStream(destination.getUri(), "wt"); - byte[] buffer = new byte[1024]; - int len; - while ((len = input.read(buffer)) != -1) { - output.write(buffer, 0, len); - } - input.close(); - output.flush(); - output.close(); - return true; - } catch (Exception e) { - Log.error("[FileUtil]: Cannot copy file, error: " + e.getMessage()); - } - return false; - } - - public static void copyDir(Context context, String sourcePath, String destinationPath, - CopyDirListener listener) { - try { - Uri sourceUri = Uri.parse(sourcePath); - Uri destinationUri = Uri.parse(destinationPath); - final List> files = new ArrayList<>(); - final List> dirs = new ArrayList<>(); - dirs.add(new Pair<>(sourceUri, destinationUri)); - // Searching all files which need to be copied and struct the directory in destination. - while (!dirs.isEmpty()) { - DocumentFile fromDir = DocumentFile.fromTreeUri(context, dirs.get(0).first); - DocumentFile toDir = DocumentFile.fromTreeUri(context, dirs.get(0).second); - if (fromDir == null || toDir == null) - continue; - Uri fromUri = fromDir.getUri(); - if (listener != null) { - listener.onSearchProgress(fromUri.getPath()); - } - CheapDocument[] documents = FileUtil.listFiles(context, fromUri); - for (CheapDocument document : documents) { - String filename = document.getFilename(); - if (document.isDirectory()) { - DocumentFile target = toDir.findFile(filename); - if (target == null || !target.exists()) { - target = toDir.createDirectory(filename); - } - if (target == null) - continue; - dirs.add(new Pair<>(document.getUri(), target.getUri())); - } else { - DocumentFile target = toDir.findFile(filename); - if (target == null || !target.exists()) { - target = - toDir.createFile(document.getMimeType(), document.getFilename()); - } - if (target == null) - continue; - files.add(new Pair<>(document, target)); - } - } - - dirs.remove(0); - } - - int total = files.size(); - int progress = 0; - for (Pair file : files) { - DocumentFile to = file.second; - Uri toUri = to.getUri(); - String toPath = toUri.getPath(); - DocumentFile toParent = to.getParentFile(); - if (toParent == null) - continue; - FileUtil.copyFile(context, file.first.getUri().toString(), - toParent.getUri().toString(), to.getName()); - progress++; - if (listener != null) { - listener.onCopyProgress(toPath, progress, total); - } - } - if (listener != null) { - listener.onComplete(); - } - } catch (Exception e) { - Log.error("[FileUtil]: Cannot copy directory, error: " + e.getMessage()); - } - } - - public static boolean renameFile(Context context, String path, String destinationFilename) { - try { - Uri uri = Uri.parse(path); - DocumentsContract.renameDocument(context.getContentResolver(), uri, destinationFilename); - return true; - } catch (Exception e) { - Log.error("[FileUtil]: Cannot rename file, error: " + e.getMessage()); - } - return false; - } - - public static boolean deleteDocument(Context context, String path) { - try { - Uri uri = Uri.parse(path); - DocumentsContract.deleteDocument(context.getContentResolver(), uri); - return true; - } catch (Exception e) { - Log.error("[FileUtil]: Cannot delete document, error: " + e.getMessage()); - } - return false; - } - - public static byte[] getBytesFromFile(Context context, DocumentFile file) throws IOException { - final Uri uri = file.getUri(); - final long length = FileUtil.getFileSize(context, uri.toString()); - - // You cannot create an array using a long type. - if (length > Integer.MAX_VALUE) { - // File is too large - throw new IOException("File is too large!"); - } - - byte[] bytes = new byte[(int) length]; - - int offset = 0; - int numRead; - - try (InputStream is = context.getContentResolver().openInputStream(uri)) { - while (offset < bytes.length && - (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) { - offset += numRead; - } - } - - // Ensure all the bytes have been read in - if (offset < bytes.length) { - throw new IOException("Could not completely read file " + file.getName()); - } - - return bytes; - } - - public static boolean isRootTreeUri(Uri uri) { - final List paths = uri.getPathSegments(); - return paths.size() == 2 && PATH_TREE.equals(paths.get(0)); - } - - public static boolean isNativePath(String path) { - try { - return path.charAt(0) == '/'; - } catch (StringIndexOutOfBoundsException e) { - Log.error("[FileUtil] Cannot determine the string is native path or not."); - } - return false; - } - - public static double getFreeSpace(Context context, Uri uri) { - try { - Uri docTreeUri = DocumentsContract.buildDocumentUriUsingTree( - uri, DocumentsContract.getTreeDocumentId(uri)); - ParcelFileDescriptor pfd = - context.getContentResolver().openFileDescriptor(docTreeUri, "r"); - assert pfd != null; - StructStatVfs stats = Os.fstatvfs(pfd.getFileDescriptor()); - double spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024; - pfd.close(); - return spaceInGigaBytes; - } catch (Exception e) { - Log.error("[FileUtil] Cannot get storage size."); - } - - return 0; - } - - public static void closeQuietly(AutoCloseable closeable) { - if (closeable != null) { - try { - closeable.close(); - } catch (RuntimeException rethrown) { - throw rethrown; - } catch (Exception ignored) { - } - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt new file mode 100644 index 000000000..402a23857 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.kt @@ -0,0 +1,598 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import okio.ByteString.Companion.readByteString +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import android.system.Os +import android.util.Pair +import androidx.documentfile.provider.DocumentFile +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.model.CheapDocument +import java.io.BufferedInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +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" + + val context: Context get() = CitraApplication.appContext + + /** + * Create a file from directory with filename. + * + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + @JvmStatic + fun createFile(directory: String, filename: String): DocumentFile? { + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) + ?: return null + val decodedFilename = URLDecoder.decode(filename, DECODE_METHOD) + val extensionPosition = decodedFilename.lastIndexOf('.') + + var extension = "" + if (extensionPosition > 0) { + extension = decodedFilename.substring(extensionPosition) + } + + var mimeType = APPLICATION_OCTET_STREAM + if (extension == ".txt") { + mimeType = TEXT_PLAIN + } + + val exists = parent.findFile(decodedFilename) + return exists ?: parent.createFile(mimeType, decodedFilename) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + return null + } + } + + /** + * Create a directory from directory with filename. + * + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + @JvmStatic + fun createDir(directory: String, directoryName: String): DocumentFile? { + try { + val directoryUri = Uri.parse(directory) + val parent: DocumentFile = + DocumentFile.fromTreeUri(context, directoryUri) + ?: return null + val decodedDirectoryName = URLDecoder.decode(directoryName, DECODE_METHOD) + val exists = parent.findFile(decodedDirectoryName) + return exists ?: parent.createDirectory(decodedDirectoryName) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + return null + } + } + + /** + * Open content uri and return file descriptor to JNI. + * + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(path: String, openMode: String): Int { + try { + context + .contentResolver + .openFileDescriptor(Uri.parse(path), openMode) + .use { parcelFileDescriptor -> + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path") + return -1 + } + return parcelFileDescriptor.detachFd() + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.message) + return -1 + } + } + + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DocumentFile.listFiles + * + * @param uri Directory uri. + * @return CheapDocument lists. + */ + @JvmStatic + fun listFiles(uri: Uri): Array { + 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 = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = context.contentResolver.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 = CheapDocument(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 + */ + @JvmStatic + fun exists(path: String): Boolean { + var c: Cursor? = null + try { + val uri = Uri.parse(path) + val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + c = context.contentResolver.query( + uri, + 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 + */ + @JvmStatic + fun isDirectory(path: String): Boolean { + val columns = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE) + var isDirectory = false + var c: Cursor? = null + try { + val uri = Uri.parse(path) + c = context.contentResolver.query(uri, columns, null, null, null) + c!!.moveToNext() + val mimeType = c.getString(0) + isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list files, error: " + e.message) + } finally { + closeQuietly(c) + } + return isDirectory + } + + /** + * Get file display name from given path + * + * @param uri content uri + * @return String display name + */ + @JvmStatic + fun getFilename(uri: Uri): String { + val columns = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + var filename = "" + var c: Cursor? = null + try { + c = context.contentResolver.query( + uri, + columns, + null, + null, + null + ) + c!!.moveToNext() + filename = c.getString(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file name, error: " + e.message) + } finally { + closeQuietly(c) + } + return filename + } + + @JvmStatic + fun getFilesName(path: String): Array { + val uri = Uri.parse(path) + val files: MutableList = ArrayList() + listFiles(uri).forEach { files.add(it.filename) } + return files.toTypedArray() + } + + /** + * Get file size from given path. + * + * @param path content uri path + * @return long file size + */ + @JvmStatic + fun getFileSize(path: String): Long { + val columns = arrayOf(DocumentsContract.Document.COLUMN_SIZE) + var size: Long = 0 + var c: Cursor? = null + try { + val uri = Uri.parse(path) + c = context.contentResolver.query( + uri, + 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 + } + + @JvmStatic + fun copyFile( + sourceUri: Uri, + destinationUri: Uri, + destinationFilename: String + ): Boolean { + try { + val destinationParent = + DocumentFile.fromTreeUri(context, destinationUri) ?: return false + val filename = URLDecoder.decode(destinationFilename, "UTF-8") + var destination = destinationParent.findFile(filename) + if (destination == null) { + destination = + destinationParent.createFile("application/octet-stream", filename) + } + if (destination == null) { + return false + } + + val input = context.contentResolver.openInputStream(sourceUri) + val output = context.contentResolver.openOutputStream(destination.uri, "wt") + val buffer = ByteArray(1024) + var len: Int + while (input!!.read(buffer).also { len = it } != -1) { + output!!.write(buffer, 0, len) + } + input.close() + output?.flush() + output?.close() + return true + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot copy file, error: " + e.message) + } + return false + } + + fun copyUriToInternalStorage( + 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 copyDir( + sourcePath: String, + destinationPath: String, + listener: CopyDirListener? + ) { + try { + val sourceUri = Uri.parse(sourcePath) + val destinationUri = Uri.parse(destinationPath) + val files: MutableList> = ArrayList() + val dirs: MutableList> = ArrayList() + dirs.add(Pair(sourceUri, destinationUri)) + + // Searching all files which need to be copied and struct the directory in destination + while (dirs.isNotEmpty()) { + val fromDir = DocumentFile.fromTreeUri(context, dirs[0].first) + val toDir = DocumentFile.fromTreeUri(context, dirs[0].second) + if (fromDir == null || toDir == null) { + continue + } + + val fromUri = fromDir.uri + listener?.onSearchProgress(fromUri.path ?: "") + val documents = listFiles(fromUri) + for (document in documents) { + // Prevent infinite recursion if the source dir is being copied to a dir within itself + if (document.filename == toDir.name) { + continue + } + + val filename = document.filename + if (document.isDirectory) { + var target = toDir.findFile(filename) + if (target == null || !target.exists()) { + target = toDir.createDirectory(filename) + } + if (target == null) { + continue + } + + dirs.add(Pair(document.uri, target.uri)) + } else { + var target = toDir.findFile(filename) + if (target == null || !target.exists()) { + target = toDir.createFile(document.mimeType, document.filename) + } + if (target == null) { + continue + } + + files.add(Pair(document, target)) + } + } + dirs.removeAt(0) + } + + var progress = 0 + for (file in files) { + val to = file.second + val toUri = to.uri + val toPath = toUri.path ?: "" + val toParent = to.parentFile ?: continue + copyFile(file.first.uri, toParent.uri, to.name!!) + progress++ + listener?.onCopyProgress(toPath, progress, files.size) + } + listener?.onComplete() + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot copy directory, error: " + e.message) + } + } + + @JvmStatic + fun renameFile(path: String, destinationFilename: String): Boolean { + try { + val uri = Uri.parse(path) + DocumentsContract.renameDocument(context.contentResolver, uri, destinationFilename) + return true + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot rename file, error: " + e.message) + } + return false + } + + @JvmStatic + fun deleteDocument(path: String): Boolean { + try { + val uri = Uri.parse(path) + DocumentsContract.deleteDocument(context.contentResolver, uri) + return true + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot delete document, error: " + e.message) + } + return false + } + + @Throws(IOException::class) + fun getBytesFromFile(file: DocumentFile): ByteArray { + val uri = file.uri + val length = getFileSize(uri.toString()) + + // You cannot create an array using a long type. + if (length > Int.MAX_VALUE) { + // File is too large + throw IOException("File is too large!") + } + + val bytes = ByteArray(length.toInt()) + + var offset = 0 + var numRead = 0 + context.contentResolver.openInputStream(uri).use { inputStream -> + while (offset < bytes.size && + inputStream!!.read(bytes, offset, bytes.size - offset).also { numRead = it } >= 0 + ) { + offset += numRead + } + } + + // Ensure all the bytes have been read in + if (offset < bytes.size) { + throw IOException("Could not completely read file " + file.name) + } + + return bytes + } + + /** + * Extracts the given zip file into the given directory. + */ + @Throws(SecurityException::class) + fun unzipToInternalStorage(zipStream: BufferedInputStream, destDir: File) { + ZipInputStream(zipStream).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + val newFile = File(destDir, entry.name) + val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile + + if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Zip file attempted path traversal! ${entry.name}") + } + + if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { + throw IOException("Failed to create directory $destinationDirectory") + } + + if (!entry.isDirectory) { + newFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + } + } + } + + fun copyToExternalStorage( + sourceFile: Uri, + destinationDir: DocumentFile + ): DocumentFile? { + val filename = getFilename(sourceFile) + val destinationFile = destinationDir.createFile("application/zip", filename)!! + destinationFile.outputStream().use { os -> + sourceFile.inputStream().use { it.copyTo(os) } + } + return destinationDir.findFile(filename) + } + + fun isRootTreeUri(uri: Uri): Boolean { + val paths = uri.pathSegments + return paths.size == 2 && PATH_TREE == paths[0] + } + + @JvmStatic + fun isNativePath(path: String): Boolean = + try { + path[0] == '/' + } catch (e: StringIndexOutOfBoundsException) { + Log.error("[FileUtil] Cannot determine the string is native path or not.") + false + } + + fun getFreeSpace(context: Context, uri: Uri?): Double = + try { + val docTreeUri = DocumentsContract.buildDocumentUriUsingTree( + uri, + DocumentsContract.getTreeDocumentId(uri) + ) + val pfd = context.contentResolver.openFileDescriptor(docTreeUri, "r")!! + val stats = Os.fstatvfs(pfd.fileDescriptor) + val spaceInGigaBytes = stats.f_bavail * stats.f_bsize / 1024.0 / 1024 / 1024 + pfd.close() + spaceInGigaBytes + } catch (e: Exception) { + Log.error("[FileUtil] Cannot get storage size.") + 0.0 + } + + fun closeQuietly(closeable: AutoCloseable?) { + if (closeable != null) { + try { + closeable.close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (ignored: Exception) { + } + } + } + + fun getExtension(uri: Uri): String { + val fileName = getFilename(uri) + return fileName.substring(fileName.lastIndexOf(".") + 1) + .lowercase() + } + + @Throws(IOException::class) + fun getStringFromFile(file: File): String = + String(file.readBytes(), StandardCharsets.UTF_8) + + @Throws(IOException::class) + fun getStringFromInputStream(stream: InputStream, length: Long = 0L): String = + if (length == 0L) { + String(stream.readBytes(), StandardCharsets.UTF_8) + } else { + String(stream.readByteString(length.toInt()).toByteArray(), StandardCharsets.UTF_8) + } + + fun DocumentFile.inputStream(): InputStream = + CitraApplication.appContext.contentResolver.openInputStream(uri)!! + + fun DocumentFile.outputStream(): OutputStream = + CitraApplication.appContext.contentResolver.openOutputStream(uri)!! + + fun Uri.inputStream(): InputStream = + CitraApplication.appContext.contentResolver.openInputStream(this)!! + + fun Uri.outputStream(): OutputStream = + CitraApplication.appContext.contentResolver.openOutputStream(this)!! + + fun Uri.asDocumentFile(): DocumentFile? = + DocumentFile.fromSingleUri(CitraApplication.appContext, this) + + interface CopyDirListener { + fun onSearchProgress(directoryName: String) + fun onCopyProgress(filename: String, progress: Int, max: Int) + fun onComplete() + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt new file mode 100644 index 000000000..9ad2e88ff --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameHelper.kt @@ -0,0 +1,107 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.model.CheapDocument +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.model.GameInfo +import java.io.IOException + +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 = CitraApplication.appContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + val gamesDir = preferences.getString(KEY_GAME_PATH, "") + val gamesUri = Uri.parse(gamesDir) + + addGamesRecursive(games, FileUtil.listFiles(gamesUri), 3) + NativeLibrary.getInstalledGamePaths().forEach { + games.add(getGame(Uri.parse(it), isInstalled = true, addedToLibrary = true)) + } + + // Cache list of games found on disk + val serializedGames = mutableSetOf() + games.forEach { + serializedGames.add(Json.encodeToString(it)) + } + preferences.edit() + .remove(KEY_GAMES) + .putStringSet(KEY_GAMES, serializedGames) + .apply() + + return games.toList() + } + + private fun addGamesRecursive( + games: MutableList, + files: Array, + depth: Int + ) { + if (depth <= 0) { + return + } + + files.forEach { + if (it.isDirectory) { + addGamesRecursive(games, FileUtil.listFiles(it.uri), depth - 1) + } else { + if (Game.allExtensions.contains(FileUtil.getExtension(it.uri))) { + games.add(getGame(it.uri, isInstalled = false, addedToLibrary = true)) + } + } + } + } + + fun getGame(uri: Uri, isInstalled: Boolean, addedToLibrary: Boolean): Game { + val filePath = uri.toString() + val gameInfo: GameInfo? = try { + GameInfo(filePath) + } catch (e: IOException) { + null + } + + val newGame = Game( + (gameInfo?.getTitle() ?: FileUtil.getFilename(uri)).replace("[\\t\\n\\r]+".toRegex(), " "), + filePath.replace("\n", " "), + filePath, + NativeLibrary.getTitleId(filePath), + gameInfo?.getCompany() ?: "", + gameInfo?.getRegions() ?: "Invalid region", + isInstalled, + NativeLibrary.getIsSystemTitle(filePath), + gameInfo?.getIsVisibleSystemTitle() ?: false, + gameInfo?.getIcon(), + if (FileUtil.isNativePath(filePath)) { + CitraApplication.documentsTree.getFilename(filePath) + } else { + FileUtil.getFilename(Uri.parse(filePath)) + } + ) + + if (addedToLibrary) { + val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) + if (addedTime == 0L) { + preferences.edit() + .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) + .apply() + } + } + + return newGame + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java deleted file mode 100644 index 7057c07ad..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.graphics.Bitmap; - -import com.squareup.picasso.Picasso; -import com.squareup.picasso.Request; -import com.squareup.picasso.RequestHandler; - -import org.citra.citra_emu.NativeLibrary; -import org.citra.citra_emu.model.GameInfo; - -import java.io.IOException; -import java.nio.IntBuffer; - -public class GameIconRequestHandler extends RequestHandler { - @Override - public boolean canHandleRequest(Request data) { - return "content".equals(data.uri.getScheme()) || data.uri.getScheme() == null; - } - - @Override - public Result load(Request request, int networkPolicy) { - int[] vector; - try { - String url = request.uri.toString(); - vector = new GameInfo(url).getIcon(); - } catch (IOException e) { - vector = null; - } - - Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565); - bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector)); - return new Result(bitmap, Picasso.LoadedFrom.DISK); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt new file mode 100644 index 000000000..6ba803ca8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconUtils.kt @@ -0,0 +1,79 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.graphics.Bitmap +import android.widget.ImageView +import androidx.core.graphics.drawable.toDrawable +import androidx.fragment.app.FragmentActivity +import coil.ImageLoader +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.key.Keyer +import coil.memory.MemoryCache +import coil.request.ImageRequest +import coil.request.Options +import coil.transform.RoundedCornersTransformation +import org.citra.citra_emu.R +import org.citra.citra_emu.model.Game +import java.nio.IntBuffer + +class GameIconFetcher( + private val game: Game, + private val options: Options +) : Fetcher { + override suspend fun fetch(): FetchResult { + return DrawableResult( + drawable = getGameIcon(game.icon)!!.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.DISK + ) + } + + private fun getGameIcon(vector: IntArray?): Bitmap? { + val bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565) + bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector)) + return bitmap + } + + class Factory : Fetcher.Factory { + override fun create(data: Game, options: Options, imageLoader: ImageLoader): Fetcher = + GameIconFetcher(data, options) + } +} + +class GameIconKeyer : Keyer { + override fun key(data: Game, options: Options): String = data.path +} + +object GameIconUtils { + fun loadGameIcon(activity: FragmentActivity, game: Game, imageView: ImageView) { + val imageLoader = ImageLoader.Builder(activity) + .components { + add(GameIconKeyer()) + add(GameIconFetcher.Factory()) + } + .memoryCache { + MemoryCache.Builder(activity) + .maxSizePercent(0.25) + .build() + } + .build() + + val request = ImageRequest.Builder(activity) + .data(game) + .target(imageView) + .error(R.drawable.no_icon) + .transformations( + RoundedCornersTransformation( + activity.resources.getDimensionPixelSize(R.dimen.spacing_med).toFloat() + ) + ) + .build() + imageLoader.enqueue(request) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt new file mode 100644 index 000000000..8ed7cd2e1 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,237 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.net.Uri +import android.os.Build +import androidx.documentfile.provider.DocumentFile +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.utils.FileUtil.asDocumentFile +import org.citra.citra_emu.utils.FileUtil.inputStream +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.lang.IllegalStateException +import java.util.zip.ZipEntry +import java.util.zip.ZipException +import java.util.zip.ZipInputStream + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private var fileRedirectionPath: String? = null + var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + val driverStoragePath: DocumentFile + get() { + // Bypass directory initialization checks + val root = DocumentFile.fromTreeUri( + CitraApplication.appContext, + Uri.parse(DirectoryInitialization.userPath) + )!! + var driverDirectory = root.findFile("gpu_drivers") + if (driverDirectory == null) { + driverDirectory = FileUtil.createDir(root.uri.toString(), "gpu_drivers") + } + return driverDirectory!! + } + + fun initializeDriverParameters() { + try { + // Initialize the file redirection directory. + fileRedirectionPath = + DirectoryInitialization.internalUserPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = CitraApplication.appContext + .filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = CitraApplication.appContext.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + customDriverData.libraryName, + fileRedirectionPath + ) + } + + fun getDrivers(): MutableList> { + val driverZips = driverStoragePath.listFiles() + val drivers: MutableList> = + driverZips + .mapNotNull { + val metadata = getMetadataFromZip(it.inputStream()) + metadata.name?.let { _ -> Pair(it.uri, metadata) } + } + .sortedByDescending { it: Pair -> it.second.name } + .distinct() + .toMutableList() + + // TODO: Get system driver information + drivers.add(0, Pair(Uri.EMPTY, GpuDriverMetadata())) + return drivers + } + + fun installDefaultDriver() { + // Removing the installed driver will result in the backend using the default system driver. + File(driverInstallationPath!!).deleteRecursively() + initializeDriverParameters() + } + + fun copyDriverToExternalStorage(driverUri: Uri): DocumentFile? { + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyToExternalStorage(driverUri, driverStoragePath) ?: return null + + // Validate driver + val metadata = getMetadataFromZip(copiedFile.inputStream()) + if (metadata.name == null) { + copiedFile.delete() + return null + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return null + } + return copiedFile + } + + /** + * Copies driver zip into user data directory so that it can be exported along with + * other user data and also unzipped into the installation directory + */ + fun installCustomDriverComplete(driverUri: Uri): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyToExternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile.inputStream()) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + + // Unzip the driver. + try { + FileUtil.unzipToInternalStorage( + BufferedInputStream(copiedFile.inputStream()), + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Unzips driver into private installation directory + */ + fun installCustomDriverPartial(driver: Uri): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Validate driver + val metadata = getMetadataFromZip(driver.inputStream()) + if (metadata.name == null) { + driver.asDocumentFile()?.delete() + return false + } + + // Unzip the driver to the private installation directory + try { + FileUtil.unzipToInternalStorage( + BufferedInputStream(driver.inputStream()), + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Takes in a zip file and reads the meta.json file for presentation to the UI + * + * @param driver Zip containing driver and meta.json file + * @return A non-null [GpuDriverMetadata] instance that may have null members + */ + fun getMetadataFromZip(driver: InputStream): GpuDriverMetadata { + try { + ZipInputStream(driver).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { + val size = if (entry.size == -1L) 0L else entry.size + return GpuDriverMetadata(zis, size) + } + entry = zis.nextEntry + } + } + } catch (_: ZipException) { + } + return GpuDriverMetadata() + } + + external fun supportsCustomDriverLoading(): Boolean + + // Parse the custom driver metadata to retrieve the name. + val customDriverData: GpuDriverMetadata + get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) + + fun initializeDirectories() { + // Ensure the file redirection directory exists. + val fileRedirectionDir = File(fileRedirectionPath!!) + if (!fileRedirectionDir.exists()) { + fileRedirectionDir.mkdirs() + } + // Ensure the driver installation directory exists. + val driverInstallationDir = File(driverInstallationPath!!) + if (!driverInstallationDir.exists()) { + driverInstallationDir.mkdirs() + } + // Ensure the driver storage directory exists + if (!driverStoragePath.exists()) { + throw IllegalStateException("Driver storage directory couldn't be created!") + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt new file mode 100644 index 000000000..2da0ccf92 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/GpuDriverMetadata.kt @@ -0,0 +1,120 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import java.io.IOException +import org.json.JSONException +import org.json.JSONObject +import java.io.File +import java.io.InputStream + +class GpuDriverMetadata { + /** + * Tries to get driver metadata information from a meta.json [File] + * + * @param metadataFile meta.json file provided with a GPU driver + */ + constructor(metadataFile: File) { + if (metadataFile.length() > MAX_META_SIZE_BYTES) { + return + } + + try { + val json = JSONObject(FileUtil.getStringFromFile(metadataFile)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + version = 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. + } + } + + /** + * Tries to get driver metadata information from an input stream that's intended to be + * from a zip file + * + * @param metadataStream ZipEntry input stream + * @param size Size of the file in bytes + */ + constructor(metadataStream: InputStream, size: Long) { + if (size > MAX_META_SIZE_BYTES) { + return + } + + try { + val json = JSONObject(FileUtil.getStringFromInputStream(metadataStream, size)) + name = json.getString("name") + description = json.getString("description") + author = json.getString("author") + vendor = json.getString("vendor") + version = 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. + } + } + + /** + * Creates an empty metadata instance + */ + constructor() + + override fun equals(other: Any?): Boolean { + if (other !is GpuDriverMetadata) { + return false + } + + return other.name == name && + other.description == description && + other.author == author && + other.vendor == vendor && + other.version == version && + other.minApi == minApi && + other.libraryName == libraryName + } + + override fun hashCode(): Int { + var result = name?.hashCode() ?: 0 + result = 31 * result + (description?.hashCode() ?: 0) + result = 31 * result + (author?.hashCode() ?: 0) + result = 31 * result + (vendor?.hashCode() ?: 0) + result = 31 * result + (version?.hashCode() ?: 0) + result = 31 * result + minApi + result = 31 * result + (libraryName?.hashCode() ?: 0) + return result + } + + override fun toString(): String = + """ + Name - $name + Description - $description + Author - $author + Vendor - $vendor + Version - $version + Min API - $minApi + Library Name - $libraryName + """.trimMargin().trimIndent() + + var name: String? = null + var description: String? = null + var author: String? = null + var vendor: String? = null + var version: String? = null + var minApi = 0 + var libraryName: String? = null + + companion object { + private const val MAX_META_SIZE_BYTES = 500000 + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java index 070d01eb1..096332422 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java @@ -8,6 +8,9 @@ import org.citra.citra_emu.BuildConfig; * levels in release builds. */ public final class Log { + // Tracks whether we should share the old log or the current log + public static boolean gameLaunched = false; + private static final String TAG = "Citra Frontend"; private Log() { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java deleted file mode 100644 index 6cbe19b76..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.Uri; -import android.preference.PreferenceManager; - -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.FragmentActivity; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; - -public class PermissionsHandler { - public static final String CITRA_DIRECTORY = "CITRA_DIRECTORY"; - public static final SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); - - // We use permissions acceptance as an indicator if this is a first boot for the user. - public static boolean isFirstBoot(FragmentActivity activity) { - return !hasWriteAccess(activity.getApplicationContext()); - } - - public static boolean checkWritePermission(FragmentActivity activity, - ActivityResultLauncher launcher) { - if (isFirstBoot(activity)) { - launcher.launch(null); - return false; - } - - return true; - } - - public static boolean hasWriteAccess(Context context) { - try { - Uri uri = getCitraDirectory(); - if (uri == null) - return false; - int takeFlags = (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - context.getContentResolver().takePersistableUriPermission(uri, takeFlags); - DocumentFile root = DocumentFile.fromTreeUri(context, uri); - if (root != null && root.exists()) return true; - context.getContentResolver().releasePersistableUriPermission(uri, takeFlags); - } catch (Exception e) { - Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.getMessage()); - } - return false; - } - - @Nullable - public static Uri getCitraDirectory() { - String directoryString = mPreferences.getString(CITRA_DIRECTORY, ""); - if (directoryString.isEmpty()) { - return null; - } - return Uri.parse(directoryString); - } - - public static boolean setCitraDirectory(String uriString) { - return mPreferences.edit().putString(CITRA_DIRECTORY, uriString).commit(); - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt new file mode 100644 index 000000000..913780964 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.kt @@ -0,0 +1,50 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import androidx.documentfile.provider.DocumentFile +import org.citra.citra_emu.CitraApplication + +object PermissionsHandler { + const val CITRA_DIRECTORY = "CITRA_DIRECTORY" + val preferences: SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + fun hasWriteAccess(context: Context): Boolean { + try { + if (citraDirectory.toString().isEmpty()) { + return false + } + + val uri = citraDirectory + val takeFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + val root = DocumentFile.fromTreeUri(context, uri) + if (root != null && root.exists()) { + return true + } + + context.contentResolver.releasePersistableUriPermission(uri, takeFlags) + } catch (e: Exception) { + Log.error("[PermissionsHandler]: Cannot check citra data directory permission, error: " + e.message) + } + return false + } + + val citraDirectory: Uri + get() { + val directoryString = preferences.getString(CITRA_DIRECTORY, "") + return Uri.parse(directoryString) + } + + fun setCitraDirectory(uriString: String?) = + preferences.edit().putString(CITRA_DIRECTORY, uriString).apply() +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java deleted file mode 100644 index 892b46387..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.graphics.Bitmap; -import android.graphics.BitmapShader; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.RectF; - -import com.squareup.picasso.Transformation; - -public class PicassoRoundedCornersTransformation implements Transformation { - @Override - public Bitmap transform(Bitmap icon) { - final int width = icon.getWidth(); - final int height = icon.getHeight(); - final Rect rect = new Rect(0, 0, width, height); - final int size = Math.min(width, height); - final int x = (width - size) / 2; - final int y = (height - size) / 2; - - Bitmap squaredBitmap = Bitmap.createBitmap(icon, x, y, size, size); - if (squaredBitmap != icon) { - icon.recycle(); - } - - Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(output); - BitmapShader shader = new BitmapShader(squaredBitmap, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP); - Paint paint = new Paint(); - paint.setAntiAlias(true); - paint.setShader(shader); - - canvas.drawRoundRect(new RectF(rect), 10, 10, paint); - - squaredBitmap.recycle(); - - return output; - } - - @Override - public String key() { - return "circle"; - } -} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java index 65d6d4a88..74e282beb 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java @@ -2,44 +2,14 @@ package org.citra.citra_emu.utils; import android.graphics.Bitmap; import android.net.Uri; -import android.widget.ImageView; import com.squareup.picasso.Picasso; -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; - import java.io.IOException; import androidx.annotation.Nullable; public class PicassoUtils { - private static boolean mPicassoInitialized = false; - - public static void init() { - if (mPicassoInitialized) { - return; - } - Picasso picassoInstance = new Picasso.Builder(CitraApplication.getAppContext()) - .addRequestHandler(new GameIconRequestHandler()) - .build(); - - Picasso.setSingletonInstance(picassoInstance); - mPicassoInitialized = true; - } - - public static void loadGameIcon(ImageView imageView, String gamePath) { - Picasso - .get() - .load(Uri.parse(gamePath)) - .fit() - .centerInside() - .config(Bitmap.Config.RGB_565) - .error(R.drawable.no_icon) - .transform(new PicassoRoundedCornersTransformation()) - .into(imageView); - } - // Blocking call. Load image from file and crop/resize it to fit in width x height. @Nullable public static Bitmap LoadBitmapFromFile(String uri, int width, int height) { diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt new file mode 100644 index 000000000..400162659 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/SerializableHelper.kt @@ -0,0 +1,42 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +@Suppress("DEPRECATION") +object SerializableHelper { + inline fun Bundle.serializable(key: String): T? = + 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? = + 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? = + 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? = + 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/citra/citra_emu/utils/StartupHandler.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java deleted file mode 100644 index 5e52529d3..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.text.TextUtils; -import android.text.method.LinkMovementMethod; -import android.widget.TextView; -import androidx.activity.result.ActivityResultLauncher; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentActivity; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.citra.citra_emu.R; -import org.citra.citra_emu.activities.EmulationActivity; - -public final class StartupHandler { - private static void handlePermissionsCheck(FragmentActivity parent, - ActivityResultLauncher launcher) { - // Ask the user to grant write permission if it's not already granted - PermissionsHandler.checkWritePermission(parent, launcher); - - String start_file = ""; - Bundle extras = parent.getIntent().getExtras(); - if (extras != null) { - start_file = extras.getString("AutoStartFile"); - } - - if (!TextUtils.isEmpty(start_file)) { - // Start the emulation activity, send the ISO passed in and finish the main activity - Intent emulation_intent = new Intent(parent, EmulationActivity.class); - emulation_intent.putExtra("SelectedGame", start_file); - parent.startActivity(emulation_intent); - parent.finish(); - } - } - - public static void HandleInit(FragmentActivity parent, ActivityResultLauncher launcher) { - if (PermissionsHandler.isFirstBoot(parent)) { - // Prompt user with standard first boot disclaimer - AlertDialog dialog = - new MaterialAlertDialogBuilder(parent) - .setTitle(R.string.app_name) - .setIcon(R.mipmap.ic_launcher) - .setMessage(R.string.app_disclaimer) - .setPositiveButton(android.R.string.ok, null) - .setCancelable(false) - .setOnDismissListener( - dialogInterface -> handlePermissionsCheck(parent, launcher)) - .show(); - TextView textView = dialog.findViewById(android.R.id.message); - if (textView == null) - return; - textView.setMovementMethod(LinkMovementMethod.getInstance()); - } - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java deleted file mode 100644 index d8c193665..000000000 --- a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.citra.citra_emu.utils; - -import android.app.Activity; -import android.content.SharedPreferences; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.graphics.Color; -import android.os.Build; -import android.preference.PreferenceManager; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -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 com.google.android.material.color.MaterialColors; - -import org.citra.citra_emu.CitraApplication; -import org.citra.citra_emu.R; -import org.citra.citra_emu.features.settings.utils.SettingsFile; - -public class ThemeUtil { - private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); - - public static final float NAV_BAR_ALPHA = 0.9f; - - private static void applyTheme(int designValue, AppCompatActivity activity) { - switch (designValue) { - case 0: - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); - break; - case 1: - AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); - break; - case 2: - AppCompatDelegate.setDefaultNightMode(android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? - AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM : - AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY); - break; - } - - setSystemBarMode(activity, getIsLightMode(activity.getResources())); - setNavigationBarColor(activity, MaterialColors.getColor(activity.getWindow().getDecorView(), R.attr.colorSurface)); - } - - public static void applyTheme(AppCompatActivity activity) { - applyTheme(mPreferences.getInt(SettingsFile.KEY_DESIGN, 0), activity); - } - - public static void setSystemBarMode(AppCompatActivity activity, boolean isLightMode) { - WindowInsetsControllerCompat windowController = WindowCompat.getInsetsController(activity.getWindow(), activity.getWindow().getDecorView()); - windowController.setAppearanceLightStatusBars(isLightMode); - windowController.setAppearanceLightNavigationBars(isLightMode); - } - - public static void setNavigationBarColor(@NonNull Activity activity, @ColorInt int color) { - int gestureType = InsetsHelper.getSystemGestureType(activity.getApplicationContext()); - int orientation = activity.getResources().getConfiguration().orientation; - - // Use a solid color when the navigation bar is on the left/right edge of the screen - if ((gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION || - gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) && - orientation == Configuration.ORIENTATION_LANDSCAPE) { - activity.getWindow().setNavigationBarColor(color); - } else if (gestureType == InsetsHelper.THREE_BUTTON_NAVIGATION || - gestureType == InsetsHelper.TWO_BUTTON_NAVIGATION) { - // Use semi-transparent color when in portrait mode with three/two button navigation to - // partially see list items behind the navigation bar - activity.getWindow().setNavigationBarColor(ThemeUtil.getColorWithOpacity(color, NAV_BAR_ALPHA)); - } else { - // Use transparent color when using gesture navigation - activity.getWindow().setNavigationBarColor( - ContextCompat.getColor(activity.getApplicationContext(), - android.R.color.transparent)); - } - } - - @ColorInt - public static int getColorWithOpacity(@ColorInt int color, float alphaFactor) { - return Color.argb(Math.round(alphaFactor * Color.alpha(color)), Color.red(color), - Color.green(color), Color.blue(color)); - } - - public static boolean getIsLightMode(Resources resources) { - return (resources.getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO; - } -} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt new file mode 100644 index 000000000..ce3d24ceb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.kt @@ -0,0 +1,76 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.content.SharedPreferences +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.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.preference.PreferenceManager +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.features.settings.model.Settings +import kotlin.math.roundToInt + +object ThemeUtil { + const val SYSTEM_BAR_ALPHA = 0.9f + + private val preferences: SharedPreferences get() = + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + + fun setTheme(activity: AppCompatActivity) { + setThemeMode(activity) + } + + 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 + } + + @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) + ) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt new file mode 100644 index 000000000..7eed05f72 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/ViewUtils.kt @@ -0,0 +1,36 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.utils + +import android.view.View + +object ViewUtils { + fun showView(view: View, length: Long = 300) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = length + alpha(1f) + }.start() + } + + fun hideView(view: View, length: Long = 300) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = length + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + }.start() + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt new file mode 100644 index 000000000..44fb8e8c4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/DriverViewModel.kt @@ -0,0 +1,150 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.utils.FileUtil.asDocumentFile +import org.citra.citra_emu.utils.GpuDriverMetadata +import org.citra.citra_emu.utils.GpuDriverHelper + +class DriverViewModel : ViewModel() { + val areDriversLoading get() = _areDriversLoading.asStateFlow() + private val _areDriversLoading = MutableStateFlow(false) + + val isDriverReady get() = _isDriverReady.asStateFlow() + private val _isDriverReady = MutableStateFlow(true) + + val isDeletingDrivers get() = _isDeletingDrivers.asStateFlow() + private val _isDeletingDrivers = MutableStateFlow(false) + + val driverList get() = _driverList.asStateFlow() + private val _driverList = MutableStateFlow(mutableListOf>()) + + var previouslySelectedDriver = 0 + var selectedDriver = -1 + + private val _selectedDriverMetadata = + MutableStateFlow( + GpuDriverHelper.customDriverData.name + ?: CitraApplication.appContext.getString(R.string.system_gpu_driver) + ) + val selectedDriverMetadata: StateFlow get() = _selectedDriverMetadata + + private val _newDriverInstalled = MutableStateFlow(false) + val newDriverInstalled: StateFlow get() = _newDriverInstalled + + val driversToDelete = mutableListOf() + + val isInteractionAllowed + get() = !areDriversLoading.value && isDriverReady.value && !isDeletingDrivers.value + + init { + _areDriversLoading.value = true + viewModelScope.launch { + withContext(Dispatchers.IO) { + val drivers = GpuDriverHelper.getDrivers() + val currentDriverMetadata = GpuDriverHelper.customDriverData + for (i in drivers.indices) { + if (drivers[i].second == currentDriverMetadata) { + setSelectedDriverIndex(i) + break + } + } + + _driverList.value = drivers + _areDriversLoading.value = false + } + } + } + + fun setSelectedDriverIndex(value: Int) { + if (selectedDriver != -1) { + previouslySelectedDriver = selectedDriver + } + selectedDriver = value + } + + fun setNewDriverInstalled(value: Boolean) { + _newDriverInstalled.value = value + } + + fun addDriver(driverData: Pair) { + val driverIndex = _driverList.value.indexOfFirst { it == driverData } + if (driverIndex == -1) { + setSelectedDriverIndex(_driverList.value.size) + _driverList.value.add(driverData) + _selectedDriverMetadata.value = driverData.second.name + ?: CitraApplication.appContext.getString(R.string.system_gpu_driver) + } else { + setSelectedDriverIndex(driverIndex) + } + } + + fun removeDriver(driverData: Pair) { + _driverList.value.remove(driverData) + } + + fun onCloseDriverManager() { + _isDeletingDrivers.value = true + viewModelScope.launch { + withContext(Dispatchers.IO) { + for (driverUri in driversToDelete) { + val driver = driverUri.asDocumentFile() ?: continue + if (driver.exists()) { + driver.delete() + } + } + driversToDelete.clear() + _isDeletingDrivers.value = false + } + } + + if (GpuDriverHelper.customDriverData == driverList.value[selectedDriver].second) { + return + } + + _isDriverReady.value = false + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (selectedDriver == 0) { + GpuDriverHelper.installDefaultDriver() + setDriverReady() + return@withContext + } + + val driverToInstall = driverList.value[selectedDriver].first.asDocumentFile() + if (driverToInstall == null) { + GpuDriverHelper.installDefaultDriver() + return@withContext + } + + if (driverToInstall.exists()) { + if (!GpuDriverHelper.installCustomDriverPartial(driverToInstall.uri)) { + return@withContext + } + } else { + GpuDriverHelper.installDefaultDriver() + } + setDriverReady() + } + } + } + + private fun setDriverReady() { + _isDriverReady.value = true + _selectedDriverMetadata.value = GpuDriverHelper.customDriverData.name + ?: CitraApplication.appContext.getString(R.string.system_gpu_driver) + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt new file mode 100644 index 000000000..0f7ed4291 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/GamesViewModel.kt @@ -0,0 +1,121 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.model.Game +import org.citra.citra_emu.utils.GameHelper + +class GamesViewModel : ViewModel() { + val games get() = _games.asStateFlow() + private val _games = MutableStateFlow(emptyList()) + + val searchedGames get() = _searchedGames.asStateFlow() + private val _searchedGames = MutableStateFlow(emptyList()) + + val isReloading get() = _isReloading.asStateFlow() + private val _isReloading = MutableStateFlow(false) + + val shouldSwapData get() = _shouldSwapData.asStateFlow() + private val _shouldSwapData = MutableStateFlow(false) + + val shouldScrollToTop get() = _shouldScrollToTop.asStateFlow() + private val _shouldScrollToTop = MutableStateFlow(false) + + val searchFocused get() = _searchFocused.asStateFlow() + private val _searchFocused = MutableStateFlow(false) + + init { + // Retrieve list of cached games + val storedGames = PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game + try { + game = Json.decodeFromString(it) + } catch (ignored: Exception) { + return@forEach + } + + val gameExists = + DocumentFile.fromSingleUri(CitraApplication.appContext, Uri.parse(game.path)) + ?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } else if (game.isInstalled) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + reloadGames(false) + } + + fun setGames(games: List) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + val filteredList = sortedList.filter { + if (it.isSystemTitle) { + it.isVisibleSystemTitle + } + true + } + + _games.value = filteredList + } + + fun setSearchedGames(games: List) { + _searchedGames.value = games + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.value = shouldSwap + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.value = shouldScroll + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.value = searchFocused + } + + fun reloadGames(directoryChanged: Boolean) { + if (isReloading.value) { + return + } + _isReloading.value = true + + viewModelScope.launch { + withContext(Dispatchers.IO) { + setGames(GameHelper.getGames()) + _isReloading.value = false + + if (directoryChanged) { + setShouldSwapData(true) + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt new file mode 100644 index 000000000..32b2449fb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/HomeViewModel.kt @@ -0,0 +1,114 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import android.content.res.Resources +import android.net.Uri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.preference.PreferenceManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.citra.citra_emu.CitraApplication +import org.citra.citra_emu.R +import org.citra.citra_emu.fragments.CitraDirectoryDialogFragment +import org.citra.citra_emu.utils.GameHelper +import org.citra.citra_emu.utils.PermissionsHandler + +class HomeViewModel : ViewModel() { + val navigationVisible get() = _navigationVisible.asStateFlow() + private val _navigationVisible = MutableStateFlow(Pair(false, false)) + + val statusBarShadeVisible get() = _statusBarShadeVisible.asStateFlow() + private val _statusBarShadeVisible = MutableStateFlow(true) + + val isPickingUserDir get() = _isPickingUserDir.asStateFlow() + private val _isPickingUserDir = MutableStateFlow(false) + + val userDir get() = _userDir.asStateFlow() + private val _userDir = MutableStateFlow( + Uri.parse( + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + .getString(PermissionsHandler.CITRA_DIRECTORY, "") + ).path ?: "" + ) + + val gamesDir get() = _gamesDir.asStateFlow() + private val _gamesDir = MutableStateFlow( + Uri.parse( + PreferenceManager.getDefaultSharedPreferences(CitraApplication.appContext) + .getString(GameHelper.KEY_GAME_PATH, "") + ).path ?: "" + ) + + var directoryListener: CitraDirectoryDialogFragment.Listener? = null + + val dirProgress get() = _dirProgress.asStateFlow() + private val _dirProgress = MutableStateFlow(0) + + val maxDirProgress get() = _maxDirProgress.asStateFlow() + private val _maxDirProgress = MutableStateFlow(0) + + val messageText get() = _messageText.asStateFlow() + private val _messageText = MutableStateFlow("") + + val copyComplete get() = _copyComplete.asStateFlow() + private val _copyComplete = MutableStateFlow(false) + + var copyInProgress = false + + var navigatedToSetup = false + + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { + if (_navigationVisible.value.first == visible) { + return + } + _navigationVisible.value = Pair(visible, animated) + } + + fun setStatusBarShadeVisibility(visible: Boolean) { + if (_statusBarShadeVisible.value == visible) { + return + } + _statusBarShadeVisible.value = visible + } + + fun setPickingUserDir(picking: Boolean) { + _isPickingUserDir.value = picking + } + + fun setUserDir(activity: FragmentActivity, dir: String) { + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + _userDir.value = dir + } + + fun setGamesDir(activity: FragmentActivity, dir: String) { + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + _gamesDir.value = dir + } + + fun clearCopyInfo() { + _messageText.value = "" + _dirProgress.value = 0 + _maxDirProgress.value = 0 + _copyComplete.value = false + copyInProgress = false + } + + fun onUpdateSearchProgress(resources: Resources, directoryName: String) { + _messageText.value = resources.getString(R.string.searching_directory, directoryName) + } + + fun onUpdateCopyProgress(resources: Resources, filename: String, progress: Int, max: Int) { + _messageText.value = resources.getString(R.string.copy_file_name, filename) + _dirProgress.value = progress + _maxDirProgress.value = max + } + + fun setCopyComplete(complete: Boolean) { + _copyComplete.value = complete + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt new file mode 100644 index 000000000..d4f654d5c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/SystemFilesViewModel.kt @@ -0,0 +1,139 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import org.citra.citra_emu.NativeLibrary +import org.citra.citra_emu.NativeLibrary.InstallStatus +import org.citra.citra_emu.utils.Log +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.CoroutineContext +import kotlin.math.min + +class SystemFilesViewModel : ViewModel() { + private var job: Job + private val coroutineContext: CoroutineContext + get() = Dispatchers.IO + job + + val isDownloading get() = _isDownloading.asStateFlow() + private val _isDownloading = MutableStateFlow(false) + + val progress get() = _progress.asStateFlow() + private val _progress = MutableStateFlow(0) + + val result get() = _result.asStateFlow() + private val _result = MutableStateFlow(null) + + val shouldRefresh get() = _shouldRefresh.asStateFlow() + private val _shouldRefresh = MutableStateFlow(false) + + private var cancelled = false + + private val RETRY_AMOUNT = 3 + + init { + job = Job() + clear() + } + + fun setShouldRefresh(refresh: Boolean) { + _shouldRefresh.value = refresh + } + + fun setProgress(progress: Int) { + _progress.value = progress + } + + fun download(titles: LongArray) { + if (isDownloading.value) { + return + } + clear() + _isDownloading.value = true + Log.debug("System menu download started.") + + val minExecutors = min(Runtime.getRuntime().availableProcessors(), titles.size) + val segment = (titles.size / minExecutors) + val atomicProgress = AtomicInteger(0) + for (i in 0 until minExecutors) { + val titlesSegment = if (i < minExecutors - 1) { + titles.copyOfRange(i * segment, (i + 1) * segment) + } else { + titles.copyOfRange(i * segment, titles.size) + } + + CoroutineScope(coroutineContext).launch { + titlesSegment.forEach { title: Long -> + // Notify UI of cancellation before ending coroutine + if (cancelled) { + _result.value = InstallStatus.ErrorAborted + cancelled = false + } + + // Takes a moment to see if the coroutine was cancelled + yield() + + // Retry downloading a title repeatedly + for (j in 0 until RETRY_AMOUNT) { + val result = tryDownloadTitle(title) + if (result == InstallStatus.Success) { + break + } else if (j == RETRY_AMOUNT - 1) { + _result.value = result + return@launch + } + Log.warning("Download for title{$title} failed, retrying in 3s...") + delay(3000L) + } + + Log.debug("Successfully installed title - $title") + setProgress(atomicProgress.incrementAndGet()) + + Log.debug("System File Progress - ${atomicProgress.get()} / ${titles.size}") + if (atomicProgress.get() == titles.size) { + _result.value = InstallStatus.Success + setShouldRefresh(true) + } + } + } + } + } + + private fun tryDownloadTitle(title: Long): InstallStatus { + val result = NativeLibrary.downloadTitleFromNus(title) + if (result != InstallStatus.Success) { + Log.error("Failed to install title $title with error - $result") + } + return result + } + + fun clear() { + Log.debug("Clearing") + job.cancelChildren() + job = Job() + _progress.value = 0 + _result.value = null + _isDownloading.value = false + cancelled = false + } + + fun cancel() { + Log.debug("Canceling system file download.") + cancelled = true + job.cancelChildren() + job = Job() + _progress.value = 0 + _result.value = InstallStatus.Cancelled + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt new file mode 100644 index 000000000..54999e4c3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/viewmodel/TaskViewModel.kt @@ -0,0 +1,59 @@ +// Copyright 2023 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class TaskViewModel : ViewModel() { + val result: StateFlow get() = _result + private val _result = MutableStateFlow(Any()) + + val isComplete: StateFlow get() = _isComplete + private val _isComplete = MutableStateFlow(false) + + val isRunning: StateFlow get() = _isRunning + private val _isRunning = MutableStateFlow(false) + + val cancelled: StateFlow get() = _cancelled + private val _cancelled = MutableStateFlow(false) + + lateinit var task: () -> Any + + fun clear() { + _result.value = Any() + _isComplete.value = false + _isRunning.value = false + _cancelled.value = false + } + + fun setCancelled(value: Boolean) { + _cancelled.value = value + } + + fun runTask() { + if (isRunning.value) { + return + } + _isRunning.value = true + + viewModelScope.launch(Dispatchers.IO) { + val res = task() + _result.value = res + _isComplete.value = true + _isRunning.value = false + } + } +} + +enum class TaskState { + Completed, + Failed, + Cancelled +} diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt index 6668f9f0d..bc0a6b94a 100644 --- a/src/android/app/src/main/jni/CMakeLists.txt +++ b/src/android/app/src/main/jni/CMakeLists.txt @@ -29,7 +29,6 @@ add_library(citra-android SHARED id_cache.cpp id_cache.h native.cpp - native.h ndk_motion.cpp ndk_motion.h ) diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp index b4864a3fb..e0808b7f3 100644 --- a/src/android/app/src/main/jni/config.cpp +++ b/src/android/app/src/main/jni/config.cpp @@ -139,9 +139,6 @@ void Config::ReadValues() { ReadSetting("Core", Settings::values.use_cpu_jit); ReadSetting("Core", Settings::values.cpu_clock_percentage); - // Premium - ReadSetting("Premium", Settings::values.texture_filter); - // Renderer Settings::values.use_gles = sdl2_config->GetBoolean("Renderer", "use_gles", true); Settings::values.shaders_accurate_mul = @@ -155,6 +152,7 @@ void Config::ReadValues() { ReadSetting("Renderer", Settings::values.resolution_factor); ReadSetting("Renderer", Settings::values.use_disk_shader_cache); ReadSetting("Renderer", Settings::values.use_vsync_new); + ReadSetting("Renderer", Settings::values.texture_filter); // Work around to map Android setting for enabling the frame limiter to the format Citra expects if (sdl2_config->GetBoolean("Renderer", "use_frame_limit", true)) { diff --git a/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp b/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp index 81dc69c4a..70b216197 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp +++ b/src/android/app/src/main/jni/emu_window/emu_window_vk.cpp @@ -51,3 +51,7 @@ bool EmuWindow_Android_Vulkan::CreateWindowSurface() { std::unique_ptr EmuWindow_Android_Vulkan::CreateSharedContext() const { return std::make_unique(driver_library); } + +std::shared_ptr EmuWindow_Android_Vulkan::GetDriverLibrary() { + return driver_library; +} diff --git a/src/android/app/src/main/jni/emu_window/emu_window_vk.h b/src/android/app/src/main/jni/emu_window/emu_window_vk.h index 58bbd3092..fe54f9a36 100644 --- a/src/android/app/src/main/jni/emu_window/emu_window_vk.h +++ b/src/android/app/src/main/jni/emu_window/emu_window_vk.h @@ -18,6 +18,8 @@ public: std::unique_ptr CreateSharedContext() const override; + std::shared_ptr GetDriverLibrary() override; + private: bool CreateWindowSurface() override; diff --git a/src/android/app/src/main/jni/game_info.cpp b/src/android/app/src/main/jni/game_info.cpp index 0f64b34a3..b80d0dc7a 100644 --- a/src/android/app/src/main/jni/game_info.cpp +++ b/src/android/app/src/main/jni/game_info.cpp @@ -147,4 +147,13 @@ jintArray Java_org_citra_citra_1emu_model_GameInfo_getIcon(JNIEnv* env, jobject return icon; } + +jboolean Java_org_citra_citra_1emu_model_GameInfo_getIsVisibleSystemTitle(JNIEnv* env, + jobject obj) { + Loader::SMDH* smdh = GetPointer(env, obj); + if (smdh == nullptr) { + return false; + } + return smdh->flags & Loader::SMDH::Flags::Visible; +} } diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp index e737d19ee..e7be21279 100644 --- a/src/android/app/src/main/jni/id_cache.cpp +++ b/src/android/app/src/main/jni/id_cache.cpp @@ -25,9 +25,6 @@ static jclass s_savestate_info_class; static jclass s_native_library_class; static jmethodID s_on_core_error; -static jmethodID s_display_alert_msg; -static jmethodID s_display_alert_prompt; -static jmethodID s_alert_prompt_button; static jmethodID s_is_portrait_mode; static jmethodID s_landscape_screen_layout; static jmethodID s_exit_emulation_activity; @@ -87,18 +84,6 @@ jmethodID GetOnCoreError() { return s_on_core_error; } -jmethodID GetDisplayAlertMsg() { - return s_display_alert_msg; -} - -jmethodID GetDisplayAlertPrompt() { - return s_display_alert_prompt; -} - -jmethodID GetAlertPromptButton() { - return s_alert_prompt_button; -} - jmethodID GetIsPortraitMode() { return s_is_portrait_mode; } @@ -182,7 +167,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { // Initialize misc classes s_savestate_info_class = reinterpret_cast( - env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SavestateInfo"))); + env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$SaveStateInfo"))); s_core_error_class = reinterpret_cast( env->NewGlobalRef(env->FindClass("org/citra/citra_emu/NativeLibrary$CoreError"))); @@ -190,24 +175,17 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { const jclass native_library_class = env->FindClass("org/citra/citra_emu/NativeLibrary"); s_native_library_class = reinterpret_cast(env->NewGlobalRef(native_library_class)); s_on_core_error = env->GetStaticMethodID( - s_native_library_class, "OnCoreError", + s_native_library_class, "onCoreError", "(Lorg/citra/citra_emu/NativeLibrary$CoreError;Ljava/lang/String;)Z"); - s_display_alert_msg = env->GetStaticMethodID(s_native_library_class, "displayAlertMsg", - "(Ljava/lang/String;Ljava/lang/String;Z)Z"); - s_display_alert_prompt = - env->GetStaticMethodID(s_native_library_class, "displayAlertPrompt", - "(Ljava/lang/String;Ljava/lang/String;I)Ljava/lang/String;"); - s_alert_prompt_button = - env->GetStaticMethodID(s_native_library_class, "alertPromptButton", "()I"); s_is_portrait_mode = env->GetStaticMethodID(s_native_library_class, "isPortraitMode", "()Z"); s_landscape_screen_layout = env->GetStaticMethodID(s_native_library_class, "landscapeScreenLayout", "()I"); s_exit_emulation_activity = env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V"); s_request_camera_permission = - env->GetStaticMethodID(s_native_library_class, "RequestCameraPermission", "()Z"); + env->GetStaticMethodID(s_native_library_class, "requestCameraPermission", "()Z"); s_request_mic_permission = - env->GetStaticMethodID(s_native_library_class, "RequestMicPermission", "()Z"); + env->GetStaticMethodID(s_native_library_class, "requestMicPermission", "()Z"); env->DeleteLocalRef(native_library_class); // Initialize Cheat @@ -225,7 +203,7 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { // Initialize GameInfo const jclass game_info_class = env->FindClass("org/citra/citra_emu/model/GameInfo"); - s_game_info_pointer = env->GetFieldID(game_info_class, "mPointer", "J"); + s_game_info_pointer = env->GetFieldID(game_info_class, "pointer", "J"); env->DeleteLocalRef(game_info_class); // Initialize Disk Shader Cache Progress Dialog @@ -262,13 +240,13 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) { env->GetMethodID(s_cia_install_helper_class, "setProgressCallback", "(II)V"); // Initialize CIA InstallStatus map jclass cia_install_status_class = - env->FindClass("org/citra/citra_emu/utils/CiaInstallWorker$InstallStatus"); + env->FindClass("org/citra/citra_emu/NativeLibrary$InstallStatus"); const auto to_java_cia_install_status = [env, cia_install_status_class](const std::string& stage) { return env->NewGlobalRef(env->GetStaticObjectField( cia_install_status_class, env->GetStaticFieldID(cia_install_status_class, stage.c_str(), - "Lorg/citra/citra_emu/utils/" - "CiaInstallWorker$InstallStatus;"))); + "Lorg/citra/citra_emu/" + "NativeLibrary$InstallStatus;"))); }; s_java_cia_install_status.emplace(Service::AM::InstallStatus::Success, to_java_cia_install_status("Success")); diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 1ce3b4a33..dbd845a84 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -3,12 +3,17 @@ // Refer to the license.txt file included. #include +#include #include #include #include #include +#include +#include +#include +#include #include "audio_core/dsp_interface.h" #include "common/arch.h" #if CITRA_ARCH(arm64) @@ -45,7 +50,6 @@ #include "jni/game_settings.h" #include "jni/id_cache.h" #include "jni/input_manager.h" -#include "jni/native.h" #include "jni/ndk_motion.h" #include "video_core/renderer_base.h" #include "video_core/video_core.h" @@ -60,6 +64,7 @@ ANativeWindow* s_surf; std::shared_ptr vulkan_library{}; std::unique_ptr window; +std::shared_ptr cfg; std::atomic stop_run{true}; std::atomic pause_emulation{false}; @@ -277,8 +282,8 @@ void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& cus extern "C" { -void Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, - [[maybe_unused]] jclass clazz, +void Java_org_citra_citra_1emu_NativeLibrary_surfaceChanged(JNIEnv* env, + [[maybe_unused]] jobject obj, jobject surf) { s_surf = ANativeWindow_fromSurface(env, surf); @@ -292,8 +297,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, LOG_INFO(Frontend, "Surface changed"); } -void Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_surfaceDestroyed([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { ANativeWindow_release(s_surf); s_surf = nullptr; if (window) { @@ -301,24 +306,23 @@ void Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, } } -void Java_org_citra_citra_1emu_NativeLibrary_DoFrame(JNIEnv* env, [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_doFrame([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { if (stop_run || pause_emulation) { return; } window->TryPresenting(); } -void JNICALL 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) { +void JNICALL Java_org_citra_citra_1emu_NativeLibrary_initializeGpuDriver( + JNIEnv* env, jobject obj, jstring hook_lib_dir, jstring custom_driver_dir, + jstring custom_driver_name, jstring file_redirect_dir) { InitializeGpuDriver(GetJString(env, hook_lib_dir), GetJString(env, custom_driver_dir), GetJString(env, custom_driver_name), GetJString(env, file_redirect_dir)); } -void Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env, - [[maybe_unused]] jclass clazz, +void Java_org_citra_citra_1emu_NativeLibrary_notifyOrientationChange([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jint layout_option, jint rotation) { Settings::values.layout_option = static_cast(layout_option); @@ -329,7 +333,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange(JNIEnv* env Camera::NDK::g_rotation = rotation; } -void Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, [[maybe_unused]] jclass clazz, +void Java_org_citra_citra_1emu_NativeLibrary_swapScreens([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jboolean swap_screens, jint rotation) { Settings::values.swap_screen = swap_screens; if (VideoCore::g_renderer) { @@ -339,13 +344,30 @@ void Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, [[maybe_un Camera::NDK::g_rotation = rotation; } -void Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory(JNIEnv* env, - [[maybe_unused]] jclass clazz, +jboolean Java_org_citra_citra_1emu_NativeLibrary_areKeysAvailable([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { + HW::AES::InitKeys(); + return HW::AES::IsKeyXAvailable(HW::AES::KeySlotID::NCCHSecure1) && + HW::AES::IsKeyXAvailable(HW::AES::KeySlotID::NCCHSecure2); +} + +jstring Java_org_citra_citra_1emu_NativeLibrary_getHomeMenuPath(JNIEnv* env, + [[maybe_unused]] jobject obj, + jint region) { + const std::string path = Core::GetHomeMenuNcchPath(region); + if (FileUtil::Exists(path)) { + return ToJString(env, path); + } + return ToJString(env, ""); +} + +void Java_org_citra_citra_1emu_NativeLibrary_setUserDirectory(JNIEnv* env, + [[maybe_unused]] jobject obj, jstring j_directory) { FileUtil::SetCurrentDir(GetJString(env, j_directory)); } -jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths( +jobjectArray Java_org_citra_citra_1emu_NativeLibrary_getInstalledGamePaths( JNIEnv* env, [[maybe_unused]] jclass clazz) { std::vector games; const FileUtil::DirectoryEntryCallable ScanDir = @@ -383,44 +405,87 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths( return jgames; } +jlongArray Java_org_citra_citra_1emu_NativeLibrary_getSystemTitleIds(JNIEnv* env, + [[maybe_unused]] jobject obj, + jint system_type, + jint region) { + const auto mode = static_cast(system_type); + const std::vector titles = Core::GetSystemTitleIds(mode, region); + jlongArray jTitles = env->NewLongArray(titles.size()); + env->SetLongArrayRegion(jTitles, 0, titles.size(), + reinterpret_cast(titles.data())); + return jTitles; +} + +jobject Java_org_citra_citra_1emu_NativeLibrary_downloadTitleFromNus([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, + jlong title) { + const auto title_id = static_cast(title); + Service::AM::InstallStatus status = Service::AM::InstallFromNus(title_id); + if (status != Service::AM::InstallStatus::Success) { + return IDCache::GetJavaCiaInstallStatus(status); + } + return IDCache::GetJavaCiaInstallStatus(Service::AM::InstallStatus::Success); +} + +[[maybe_unused]] static bool CheckKgslPresent() { + constexpr auto KgslPath{"/dev/kgsl-3d0"}; + + return access(KgslPath, F_OK) == 0; +} + +[[maybe_unused]] bool SupportsCustomDriver() { + return android_get_device_api_level() >= 28 && CheckKgslPresent(); +} + +jboolean JNICALL Java_org_citra_citra_1emu_utils_GpuDriverHelper_supportsCustomDriverLoading( + JNIEnv* env, jobject instance) { +#ifdef CITRA_ARCH_arm64 + // If the KGSL device exists custom drivers can be loaded using adrenotools + return SupportsCustomDriver(); +#else + return false; +#endif +} + // TODO(xperia64): ensure these cannot be called in an invalid state (e.g. after StopEmulation) -void Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_unPauseEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { pause_emulation = false; running_cv.notify_all(); InputManager::NDKMotionHandler()->EnableSensors(); } -void Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_pauseEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { pause_emulation = true; InputManager::NDKMotionHandler()->DisableSensors(); } -void Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_stopEmulation([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { stop_run = true; pause_emulation = false; window->StopPresenting(); running_cv.notify_all(); } -jboolean Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +jboolean Java_org_citra_citra_1emu_NativeLibrary_isRunning([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { return static_cast(!stop_run); } -jlong Java_org_citra_citra_1emu_NativeLibrary_GetRunningTitleId(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +jlong Java_org_citra_citra_1emu_NativeLibrary_getRunningTitleId([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { u64 title_id{}; Core::System::GetInstance().GetAppLoader().ReadProgramId(title_id); return static_cast(title_id); } -jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(JNIEnv* env, - [[maybe_unused]] jclass clazz, - jstring j_device, jint j_button, - jint action) { +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, + [[maybe_unused]] jstring j_device, + jint j_button, jint action) { bool consumed{}; if (action) { consumed = InputManager::ButtonHandler()->PressKey(j_button); @@ -431,10 +496,9 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent(JNIEnv* env, return static_cast(consumed); } -jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(JNIEnv* env, - [[maybe_unused]] jclass clazz, - jstring j_device, jint axis, - jfloat x, jfloat y) { +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj, [[maybe_unused]] jstring j_device, + jint axis, jfloat x, jfloat y) { // Clamp joystick movement to supported minimum and maximum // Citra uses an inverted y axis sent by the frontend x = std::clamp(x, -1.f, 1.f); @@ -451,29 +515,28 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent(JNIEnv* env, return static_cast(InputManager::AnalogHandler()->MoveJoystick(axis, x, y)); } -jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent(JNIEnv* env, - [[maybe_unused]] jclass clazz, - jstring j_device, jint axis_id, - jfloat axis_val) { +jboolean Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj, [[maybe_unused]] jstring j_device, + jint axis_id, jfloat axis_val) { return static_cast( InputManager::ButtonHandler()->AnalogButtonEvent(axis_id, axis_val)); } -jboolean Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, - [[maybe_unused]] jclass clazz, +jboolean Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jfloat x, jfloat y, jboolean pressed) { return static_cast( window->OnTouchEvent(static_cast(x + 0.5), static_cast(y + 0.5), pressed)); } -void Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, - [[maybe_unused]] jclass clazz, jfloat x, +void Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jfloat x, jfloat y) { window->OnTouchMoved((int)x, (int)y); } -jlong Java_org_citra_citra_1emu_NativeLibrary_GetTitleId(JNIEnv* env, [[maybe_unused]] jclass clazz, +jlong Java_org_citra_citra_1emu_NativeLibrary_getTitleId(JNIEnv* env, [[maybe_unused]] jobject obj, jstring j_filename) { std::string filepath = GetJString(env, j_filename); const auto loader = Loader::GetLoader(filepath); @@ -485,42 +548,44 @@ jlong Java_org_citra_citra_1emu_NativeLibrary_GetTitleId(JNIEnv* env, [[maybe_un return static_cast(title_id); } -jstring Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, - [[maybe_unused]] jclass clazz) { - return nullptr; +jboolean Java_org_citra_citra_1emu_NativeLibrary_getIsSystemTitle(JNIEnv* env, + [[maybe_unused]] jobject obj, + jstring path) { + const std::string filepath = GetJString(env, path); + const auto loader = Loader::GetLoader(filepath); + + // Since we also read through invalid file extensions, we have to check if the loader is valid + if (loader == nullptr) { + return false; + } + + u64 program_id = 0; + loader->ReadProgramId(program_id); + return ((program_id >> 32) & 0xFFFFFFFF) == 0x00040010; } -void Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_createConfigFile([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { Config{}; } -void Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_createLogFile([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { Common::Log::Initialize(); Common::Log::Start(); LOG_INFO(Frontend, "Logging backend initialised"); } -void Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env, - [[maybe_unused]] jclass clazz, +void Java_org_citra_citra_1emu_NativeLibrary_logUserDirectory(JNIEnv* env, + [[maybe_unused]] jobject obj, jstring j_path) { std::string_view path = env->GetStringUTFChars(j_path, 0); LOG_INFO(Frontend, "User directory path: {}", path); env->ReleaseStringUTFChars(j_path, path.data()); } -jint Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, - [[maybe_unused]] jclass clazz) { - return 0; -} - -void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z( - JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_file, jstring j_savestate, - jboolean j_delete_savestate) {} - -void Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { Config{}; Core::System& system{Core::System::GetInstance()}; @@ -534,51 +599,8 @@ void Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, system.ApplySettings(); } -jstring Java_org_citra_citra_1emu_NativeLibrary_GetUserSetting(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); - - // TODO - - 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_citra_citra_1emu_NativeLibrary_SetUserSetting(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); - - // TODO - - 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_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env, [[maybe_unused]] jclass clazz, - jstring j_game_id) { - std::string_view game_id = env->GetStringUTFChars(j_game_id, 0); - - // TODO - - env->ReleaseStringUTFChars(j_game_id, game_id.data()); -} - -jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, - [[maybe_unused]] jclass clazz) { +jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_getPerfStats(JNIEnv* env, + [[maybe_unused]] jobject obj) { auto& core = Core::System::GetInstance(); jdoubleArray j_stats = env->NewDoubleArray(4); @@ -595,15 +617,8 @@ jdoubleArray Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, return j_stats; } -void Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory( - JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_path) { - std::string_view path = env->GetStringUTFChars(j_path, 0); - - env->ReleaseStringUTFChars(j_path, path.data()); -} - -void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(JNIEnv* env, - [[maybe_unused]] jclass clazz, +void Java_org_citra_citra_1emu_NativeLibrary_run__Ljava_lang_String_2(JNIEnv* env, + [[maybe_unused]] jobject obj, jstring j_path) { const std::string path = GetJString(env, j_path); @@ -619,13 +634,15 @@ void Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2(JNIEnv* en } } -void Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env, jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_reloadCameraDevices([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { if (g_ndk_factory) { g_ndk_factory->ReloadCameraDevices(); } } -jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz, +jboolean Java_org_citra_citra_1emu_NativeLibrary_loadAmiibo(JNIEnv* env, + [[maybe_unused]] jobject obj, jstring j_file) { std::string filepath = GetJString(env, j_file); Core::System& system{Core::System::GetInstance()}; @@ -638,7 +655,8 @@ jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass return static_cast(nfc->LoadAmiibo(filepath)); } -void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_removeAmiibo([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { Core::System& system{Core::System::GetInstance()}; Service::SM::ServiceManager& sm = system.ServiceManager(); auto nfc = sm.GetService("nfc:u"); @@ -649,7 +667,7 @@ void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass cl nfc->RemoveAmiibo(); } -JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_InstallCIA( +JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_installCIA( JNIEnv* env, jobject jobj, jstring jpath) { std::string path = GetJString(env, jpath); Service::AM::InstallStatus res = @@ -661,8 +679,8 @@ JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_utils_CiaInstallWorker_Insta return IDCache::GetJavaCiaInstallStatus(res); } -jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo( - JNIEnv* env, [[maybe_unused]] jclass clazz) { +jobjectArray Java_org_citra_citra_1emu_NativeLibrary_getSavestateInfo( + JNIEnv* env, [[maybe_unused]] jobject obj) { const jclass date_class = env->FindClass("java/util/Date"); const auto date_constructor = env->GetMethodID(date_class, "", "(J)V"); @@ -695,15 +713,18 @@ jobjectArray Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo( return array; } -void Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz, jint slot) { +void Java_org_citra_citra_1emu_NativeLibrary_saveState([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jint slot) { Core::System::GetInstance().SendSignal(Core::System::Signal::Save, slot); } -void Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz, jint slot) { +void Java_org_citra_citra_1emu_NativeLibrary_loadState([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, jint slot) { Core::System::GetInstance().SendSignal(Core::System::Signal::Load, slot); } -void Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass clazz) { +void Java_org_citra_citra_1emu_NativeLibrary_logDeviceInfo([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { LOG_INFO(Frontend, "Citra Version: {} | {}-{}", Common::g_build_fullname, Common::g_scm_branch, Common::g_scm_desc); LOG_INFO(Frontend, "Host CPU: {}", Common::GetCPUCaps().cpu_string); @@ -711,4 +732,29 @@ void Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, jclass c LOG_INFO(Frontend, "Host OS: Android API level {}", android_get_device_api_level()); } +void Java_org_citra_citra_1emu_NativeLibrary_loadSystemConfig([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { + if (Core::System::GetInstance().IsPoweredOn()) { + cfg = Service::CFG::GetModule(Core::System::GetInstance()); + } else { + cfg = std::make_shared(); + } +} + +void Java_org_citra_citra_1emu_NativeLibrary_saveSystemConfig([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj) { + cfg->UpdateConfigNANDSavegame(); +} + +void Java_org_citra_citra_1emu_NativeLibrary_setSystemSetupNeeded([[maybe_unused]] JNIEnv* env, + [[maybe_unused]] jobject obj, + jboolean needed) { + cfg->SetSystemSetupNeeded(needed); +} + +jboolean Java_org_citra_citra_1emu_NativeLibrary_getIsSystemSetupNeeded( + [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) { + return cfg->IsSystemSetupNeeded(); +} + } // extern "C" diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h deleted file mode 100644 index 5733a7b56..000000000 --- a/src/android/app/src/main/jni/native.h +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2019 Citra Emulator Project -// Licensed under GPLv2 or any later version -// Refer to the license.txt file included. - -#pragma once - -#include - -// Function calls from the Java side -#ifdef __cplusplus -extern "C" { -#endif - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_PauseEmulation(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_StopEmulation(JNIEnv* env, - jclass clazz); - -JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_IsRunning(JNIEnv* env, - jclass clazz); - -JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadEvent( - JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action); - -JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadMoveEvent( - JNIEnv* env, jclass clazz, jstring j_device, jint axis, jfloat x, jfloat y); - -JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onGamePadAxisEvent( - JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val); - -JNIEXPORT jboolean JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchEvent(JNIEnv* env, - jclass clazz, - jfloat x, jfloat y, - jboolean pressed); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, - jclass clazz, jfloat x, - jfloat y); - -JNIEXPORT jintArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetIcon(JNIEnv* env, - jclass clazz, - jstring j_file); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetTitle(JNIEnv* env, - jclass clazz, - jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetDescription( - JNIEnv* env, jclass clazz, jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGameId(JNIEnv* env, - jclass clazz, - jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetRegions(JNIEnv* env, - jclass clazz, - jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetCompany(JNIEnv* env, - jclass clazz, - jstring j_filename); - -JNIEXPORT jstring JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetGitRevision(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetUserDirectory( - JNIEnv* env, jclass clazz, jstring j_directory); - -JNIEXPORT jobjectArray JNICALL -Java_org_citra_citra_1emu_NativeLibrary_GetInstalledGamePaths(JNIEnv* env, jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_utils_DirectoryInitialization_SetSysDirectory( - JNIEnv* env, jclass clazz, jstring path_); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env, - jclass clazz, - jstring path); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateConfigFile(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_CreateLogFile(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogUserDirectory(JNIEnv* env, - jclass clazz, - jstring path); - -JNIEXPORT jint JNICALL Java_org_citra_citra_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env, - jclass clazz); -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SetProfiling(JNIEnv* env, - jclass clazz, - jboolean enable); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_NotifyOrientationChange( - JNIEnv* env, jclass clazz, jint layout_option, jint rotation); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SwapScreens(JNIEnv* env, - jclass clazz, - jboolean swap_screens, - jint rotation); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_Run__Ljava_lang_String_2( - JNIEnv* env, jclass clazz, jstring j_path); - -JNIEXPORT void JNICALL -Java_org_citra_citra_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_citra_citra_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env, - jclass clazz, - jobject surf); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_InitGameIni(JNIEnv* env, - jclass clazz, - jstring j_game_id); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadSettings(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_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_citra_citra_1emu_NativeLibrary_GetUserSetting( - JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key); - -JNIEXPORT jdoubleArray JNICALL Java_org_citra_citra_1emu_NativeLibrary_GetPerfStats(JNIEnv* env, - jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_ReloadCameraDevices(JNIEnv* env, - jclass clazz); - -JNIEXPORT jboolean Java_org_citra_citra_1emu_NativeLibrary_LoadAmiibo(JNIEnv* env, jclass clazz, - jstring j_file); - -JNIEXPORT void Java_org_citra_citra_1emu_NativeLibrary_RemoveAmiibo(JNIEnv* env, jclass clazz); - -JNIEXPORT jobjectArray JNICALL -Java_org_citra_citra_1emu_NativeLibrary_GetSavestateInfo(JNIEnv* env, jclass clazz); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_SaveState(JNIEnv* env, jclass clazz, - jint slot); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LoadState(JNIEnv* env, jclass clazz, - jint slot); - -JNIEXPORT void JNICALL Java_org_citra_citra_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env, - jclass clazz); - -JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_NativeLibrary_InstallCIA(JNIEnv* env, - jclass clazz, - jstring file); - -#ifdef __cplusplus -} -#endif diff --git a/src/android/app/src/main/jni/ndk_motion.cpp b/src/android/app/src/main/jni/ndk_motion.cpp index 0eab444a9..9f4d9b9d2 100644 --- a/src/android/app/src/main/jni/ndk_motion.cpp +++ b/src/android/app/src/main/jni/ndk_motion.cpp @@ -6,7 +6,6 @@ #include "common/assert.h" #include "common/logging/log.h" #include "common/vector_math.h" -#include "jni/native.h" #include "jni/ndk_motion.h" namespace InputManager { 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 100644 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_camera.xml b/src/android/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 000000000..7c3564224 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_camera.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 100644 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_citra_full.xml b/src/android/app/src/main/res/drawable/ic_citra_full.xml new file mode 100644 index 000000000..99598383e --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citra_full.xml @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 100644 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 100644 index 000000000..329e90576 --- /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_controller_outline.xml b/src/android/app/src/main/res/drawable/ic_controller_outline.xml new file mode 100644 index 000000000..4e0f053d9 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_controller_outline.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_delete.xml b/src/android/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 000000000..d26a79711 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_delete.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 100644 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_github.xml b/src/android/app/src/main/res/drawable/ic_github.xml new file mode 100644 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_home.xml b/src/android/app/src/main/res/drawable/ic_home.xml new file mode 100644 index 000000000..e6a510cf5 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_home.xml @@ -0,0 +1,9 @@ + + + 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 100644 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_driver.xml b/src/android/app/src/main/res/drawable/ic_install_driver.xml new file mode 100644 index 000000000..8514919e0 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_install_driver.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_microphone.xml b/src/android/app/src/main/res/drawable/ic_microphone.xml new file mode 100644 index 000000000..96b6194ce --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_microphone.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_more.xml b/src/android/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 000000000..a39afeeb2 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_more.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 100644 index 000000000..d3a493bfc --- /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_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml new file mode 100644 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_outline.xml b/src/android/app/src/main/res/drawable/ic_settings_outline.xml new file mode 100644 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_share.xml b/src/android/app/src/main/res/drawable/ic_share.xml new file mode 100644 index 000000000..3fc2f3c99 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_share.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/ic_system_update.xml b/src/android/app/src/main/res/drawable/ic_system_update.xml new file mode 100644 index 000000000..e72012412 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_system_update.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 100644 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/selector_controller.xml b/src/android/app/src/main/res/drawable/selector_controller.xml new file mode 100644 index 000000000..1905c80f1 --- /dev/null +++ b/src/android/app/src/main/res/drawable/selector_controller.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 100644 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/layout-w600dp/activity_main.xml b/src/android/app/src/main/res/layout-w600dp/activity_main.xml new file mode 100644 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 100644 index 000000000..406df9eab --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml new file mode 100644 index 000000000..6c833f876 --- /dev/null +++ b/src/android/app/src/main/res/layout-w600dp/fragment_system_files.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +