From 9427e27e24a7135880ee2881c3c44988e174b41a Mon Sep 17 00:00:00 2001 From: Zephyron Date: Tue, 31 Dec 2024 16:19:25 +1000 Subject: chore: update project branding to citron --- src/android/app/src/ea/res/drawable/ic_citron.xml | 22 + .../app/src/ea/res/drawable/ic_citron_full.xml | 12 + .../app/src/ea/res/drawable/ic_citron_title.xml | 24 + src/android/app/src/ea/res/drawable/ic_yuzu.xml | 22 - .../app/src/ea/res/drawable/ic_yuzu_full.xml | 12 - .../app/src/ea/res/drawable/ic_yuzu_title.xml | 24 - .../main/java/org/citron/yuzu_emu/NativeLibrary.kt | 462 +++++++++ .../java/org/citron/yuzu_emu/YuzuApplication.kt | 55 + .../yuzu_emu/activities/EmulationActivity.kt | 509 ++++++++++ .../yuzu_emu/adapters/AbstractDiffAdapter.kt | 38 + .../yuzu_emu/adapters/AbstractListAdapter.kt | 98 ++ .../adapters/AbstractSingleSelectionList.kt | 105 ++ .../org/citron/yuzu_emu/adapters/AddonAdapter.kt | 37 + .../org/citron/yuzu_emu/adapters/AppletAdapter.kt | 74 ++ .../adapters/CabinetLauncherDialogAdapter.kt | 59 ++ .../org/citron/yuzu_emu/adapters/DriverAdapter.kt | 59 ++ .../org/citron/yuzu_emu/adapters/FolderAdapter.kt | 48 + .../org/citron/yuzu_emu/adapters/GameAdapter.kt | 99 ++ .../yuzu_emu/adapters/GamePropertiesAdapter.kt | 115 +++ .../citron/yuzu_emu/adapters/HomeSettingAdapter.kt | 84 ++ .../citron/yuzu_emu/adapters/InstallableAdapter.kt | 35 + .../org/citron/yuzu_emu/adapters/LicenseAdapter.kt | 39 + .../org/citron/yuzu_emu/adapters/SetupAdapter.kt | 75 ++ .../yuzu_emu/applets/keyboard/SoftwareKeyboard.kt | 124 +++ .../applets/keyboard/ui/KeyboardDialogFragment.kt | 100 ++ .../disk_shader_cache/DiskShaderCacheProgress.kt | 51 + .../citron/yuzu_emu/features/DocumentProvider.kt | 341 +++++++ .../citron/yuzu_emu/features/input/NativeInput.kt | 416 ++++++++ .../yuzu_emu/features/input/YuzuInputDevice.kt | 93 ++ .../citron/yuzu_emu/features/input/YuzuVibrator.kt | 76 ++ .../features/input/model/AnalogDirection.kt | 11 + .../yuzu_emu/features/input/model/ButtonName.kt | 19 + .../yuzu_emu/features/input/model/InputType.kt | 13 + .../yuzu_emu/features/input/model/NativeAnalog.kt | 14 + .../yuzu_emu/features/input/model/NativeButton.kt | 38 + .../yuzu_emu/features/input/model/NativeTrigger.kt | 10 + .../features/input/model/NpadStyleIndex.kt | 30 + .../yuzu_emu/features/input/model/PlayerInput.kt | 83 ++ .../settings/model/AbstractBooleanSetting.kt | 9 + .../features/settings/model/AbstractByteSetting.kt | 9 + .../settings/model/AbstractFloatSetting.kt | 9 + .../features/settings/model/AbstractIntSetting.kt | 9 + .../features/settings/model/AbstractLongSetting.kt | 9 + .../features/settings/model/AbstractSetting.kt | 31 + .../settings/model/AbstractShortSetting.kt | 9 + .../settings/model/AbstractStringSetting.kt | 9 + .../features/settings/model/BooleanSetting.kt | 46 + .../features/settings/model/ByteSetting.kt | 25 + .../features/settings/model/FloatSetting.kt | 26 + .../yuzu_emu/features/settings/model/IntSetting.kt | 45 + .../features/settings/model/LongSetting.kt | 25 + .../yuzu_emu/features/settings/model/Settings.kt | 120 +++ .../features/settings/model/ShortSetting.kt | 25 + .../features/settings/model/StringSetting.kt | 26 + .../settings/model/view/AnalogInputSetting.kt | 31 + .../settings/model/view/ButtonInputSetting.kt | 29 + .../settings/model/view/DateTimeSetting.kt | 20 + .../features/settings/model/view/HeaderSetting.kt | 13 + .../settings/model/view/InputProfileSetting.kt | 32 + .../features/settings/model/view/InputSetting.kt | 134 +++ .../settings/model/view/IntSingleChoiceSetting.kt | 38 + .../settings/model/view/ModifierInputSetting.kt | 31 + .../settings/model/view/RunnableSetting.kt | 19 + .../features/settings/model/view/SettingsItem.kt | 391 ++++++++ .../settings/model/view/SingleChoiceSetting.kt | 29 + .../features/settings/model/view/SliderSetting.kt | 42 + .../settings/model/view/StringInputSetting.kt | 22 + .../model/view/StringSingleChoiceSetting.kt | 35 + .../features/settings/model/view/SubmenuSetting.kt | 19 + .../features/settings/model/view/SwitchSetting.kt | 34 + .../features/settings/ui/InputDialogFragment.kt | 300 ++++++ .../features/settings/ui/InputProfileAdapter.kt | 68 ++ .../settings/ui/InputProfileDialogFragment.kt | 148 +++ .../settings/ui/NewInputProfileDialogFragment.kt | 79 ++ .../features/settings/ui/SettingsActivity.kt | 171 ++++ .../features/settings/ui/SettingsAdapter.kt | 434 ++++++++ .../features/settings/ui/SettingsDialogFragment.kt | 301 ++++++ .../features/settings/ui/SettingsFragment.kt | 182 ++++ .../settings/ui/SettingsFragmentPresenter.kt | 975 ++++++++++++++++++ .../features/settings/ui/SettingsSearchFragment.kt | 183 ++++ .../features/settings/ui/SettingsViewModel.kt | 112 +++ .../settings/ui/viewholder/DateTimeViewHolder.kt | 54 + .../settings/ui/viewholder/HeaderViewHolder.kt | 30 + .../ui/viewholder/InputProfileViewHolder.kt | 34 + .../settings/ui/viewholder/InputViewHolder.kt | 60 ++ .../settings/ui/viewholder/RunnableViewHolder.kt | 50 + .../settings/ui/viewholder/SettingViewHolder.kt | 54 + .../ui/viewholder/SingleChoiceViewHolder.kt | 91 ++ .../settings/ui/viewholder/SliderViewHolder.kt | 50 + .../ui/viewholder/StringInputViewHolder.kt | 45 + .../settings/ui/viewholder/SubmenuViewHolder.kt | 46 + .../ui/viewholder/SwitchSettingViewHolder.kt | 51 + .../features/settings/utils/SettingsFile.kt | 29 + .../org/citron/yuzu_emu/fragments/AboutFragment.kt | 124 +++ .../fragments/AddGameFolderDialogFragment.kt | 56 ++ .../citron/yuzu_emu/fragments/AddonsFragment.kt | 205 ++++ .../yuzu_emu/fragments/AppletLauncherFragment.kt | 106 ++ .../fragments/CabinetLauncherDialogFragment.kt | 41 + .../ContentTypeSelectionDialogFragment.kt | 68 ++ .../yuzu_emu/fragments/CoreErrorDialogFragment.kt | 47 + .../yuzu_emu/fragments/DriverManagerFragment.kt | 199 ++++ .../fragments/DriversLoadingDialogFragment.kt | 50 + .../yuzu_emu/fragments/EarlyAccessFragment.kt | 87 ++ .../citron/yuzu_emu/fragments/EmulationFragment.kt | 1048 +++++++++++++++++++ .../GameFolderPropertiesDialogFragment.kt | 78 ++ .../yuzu_emu/fragments/GameFoldersFragment.kt | 116 +++ .../citron/yuzu_emu/fragments/GameInfoFragment.kt | 179 ++++ .../yuzu_emu/fragments/GamePropertiesFragment.kt | 424 ++++++++ .../yuzu_emu/fragments/HomeSettingsFragment.kt | 437 ++++++++ .../yuzu_emu/fragments/InstallableFragment.kt | 323 ++++++ .../yuzu_emu/fragments/LaunchGameDialogFragment.kt | 61 ++ .../fragments/LicenseBottomSheetDialogFragment.kt | 59 ++ .../citron/yuzu_emu/fragments/LicensesFragment.kt | 132 +++ .../yuzu_emu/fragments/MessageDialogFragment.kt | 195 ++++ .../fragments/PermissionDeniedDialogFragment.kt | 38 + .../yuzu_emu/fragments/ProgressDialogFragment.kt | 148 +++ .../fragments/ResetSettingsDialogFragment.kt | 30 + .../citron/yuzu_emu/fragments/SearchFragment.kt | 218 ++++ .../org/citron/yuzu_emu/fragments/SetupFragment.kt | 396 ++++++++ .../fragments/SetupWarningDialogFragment.kt | 86 ++ .../yuzu_emu/layout/AutofitGridLayoutManager.kt | 63 ++ .../org/citron/yuzu_emu/model/AddonViewModel.kt | 97 ++ .../main/java/org/citron/yuzu_emu/model/Applet.kt | 55 + .../main/java/org/citron/yuzu_emu/model/Driver.kt | 27 + .../org/citron/yuzu_emu/model/DriverViewModel.kt | 196 ++++ .../citron/yuzu_emu/model/EmulationViewModel.kt | 76 ++ .../main/java/org/citron/yuzu_emu/model/Game.kt | 103 ++ .../main/java/org/citron/yuzu_emu/model/GameDir.kt | 13 + .../org/citron/yuzu_emu/model/GameProperties.kt | 36 + .../yuzu_emu/model/GameVerificationResult.kt | 15 + .../org/citron/yuzu_emu/model/GamesViewModel.kt | 186 ++++ .../java/org/citron/yuzu_emu/model/HomeSetting.kt | 18 + .../org/citron/yuzu_emu/model/HomeViewModel.kt | 76 ++ .../org/citron/yuzu_emu/model/InstallResult.kt | 15 + .../java/org/citron/yuzu_emu/model/Installable.kt | 13 + .../main/java/org/citron/yuzu_emu/model/License.kt | 16 + .../yuzu_emu/model/MessageDialogViewModel.kt | 16 + .../citron/yuzu_emu/model/MinimalDocumentFile.kt | 11 + .../main/java/org/citron/yuzu_emu/model/Patch.kt | 16 + .../java/org/citron/yuzu_emu/model/PatchType.kt | 14 + .../org/citron/yuzu_emu/model/SelectableItem.kt | 9 + .../java/org/citron/yuzu_emu/model/SetupPage.kt | 29 + .../org/citron/yuzu_emu/model/TaskViewModel.kt | 83 ++ .../org/citron/yuzu_emu/overlay/InputOverlay.kt | 1049 ++++++++++++++++++++ .../yuzu_emu/overlay/InputOverlayDrawableButton.kt | 151 +++ .../yuzu_emu/overlay/InputOverlayDrawableDpad.kt | 266 +++++ .../overlay/InputOverlayDrawableJoystick.kt | 292 ++++++ .../yuzu_emu/overlay/model/OverlayControl.kt | 188 ++++ .../yuzu_emu/overlay/model/OverlayControlData.kt | 19 + .../overlay/model/OverlayControlDefault.kt | 13 + .../citron/yuzu_emu/overlay/model/OverlayLayout.kt | 10 + .../java/org/citron/yuzu_emu/ui/GamesFragment.kt | 160 +++ .../org/citron/yuzu_emu/ui/main/MainActivity.kt | 692 +++++++++++++ .../org/citron/yuzu_emu/ui/main/ThemeProvider.kt | 11 + .../java/org/citron/yuzu_emu/utils/AddonUtil.kt | 8 + .../yuzu_emu/utils/DirectoryInitialization.kt | 213 ++++ .../org/citron/yuzu_emu/utils/DocumentsTree.kt | 137 +++ .../java/org/citron/yuzu_emu/utils/FileUtil.kt | 503 ++++++++++ .../java/org/citron/yuzu_emu/utils/GameHelper.kt | 152 +++ .../org/citron/yuzu_emu/utils/GameIconUtils.kt | 109 ++ .../java/org/citron/yuzu_emu/utils/GameMetadata.kt | 22 + .../org/citron/yuzu_emu/utils/GpuDriverHelper.kt | 229 +++++ .../org/citron/yuzu_emu/utils/GpuDriverMetadata.kt | 119 +++ .../java/org/citron/yuzu_emu/utils/InputHandler.kt | 94 ++ .../java/org/citron/yuzu_emu/utils/InsetsHelper.kt | 25 + .../org/citron/yuzu_emu/utils/LifecycleUtils.kt | 38 + .../src/main/java/org/citron/yuzu_emu/utils/Log.kt | 31 + .../java/org/citron/yuzu_emu/utils/MemoryUtil.kt | 111 +++ .../java/org/citron/yuzu_emu/utils/NativeConfig.kt | 186 ++++ .../java/org/citron/yuzu_emu/utils/NfcReader.kt | 171 ++++ .../java/org/citron/yuzu_emu/utils/ParamPackage.kt | 141 +++ .../org/citron/yuzu_emu/utils/PreferenceUtil.kt | 37 + .../citron/yuzu_emu/utils/SerializableHelper.kt | 44 + .../java/org/citron/yuzu_emu/utils/ThemeHelper.kt | 105 ++ .../java/org/citron/yuzu_emu/utils/ViewUtils.kt | 93 ++ .../yuzu_emu/viewholder/AbstractViewHolder.kt | 18 + .../citron/yuzu_emu/views/FixedRatioSurfaceView.kt | 64 ++ .../main/java/org/yuzu/yuzu_emu/NativeLibrary.kt | 462 --------- .../main/java/org/yuzu/yuzu_emu/YuzuApplication.kt | 55 - .../yuzu/yuzu_emu/activities/EmulationActivity.kt | 509 ---------- .../yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt | 38 - .../yuzu/yuzu_emu/adapters/AbstractListAdapter.kt | 98 -- .../adapters/AbstractSingleSelectionList.kt | 105 -- .../org/yuzu/yuzu_emu/adapters/AddonAdapter.kt | 37 - .../org/yuzu/yuzu_emu/adapters/AppletAdapter.kt | 74 -- .../adapters/CabinetLauncherDialogAdapter.kt | 59 -- .../org/yuzu/yuzu_emu/adapters/DriverAdapter.kt | 59 -- .../org/yuzu/yuzu_emu/adapters/FolderAdapter.kt | 48 - .../java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt | 99 -- .../yuzu_emu/adapters/GamePropertiesAdapter.kt | 115 --- .../yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt | 84 -- .../yuzu/yuzu_emu/adapters/InstallableAdapter.kt | 35 - .../org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt | 39 - .../org/yuzu/yuzu_emu/adapters/SetupAdapter.kt | 75 -- .../yuzu_emu/applets/keyboard/SoftwareKeyboard.kt | 124 --- .../applets/keyboard/ui/KeyboardDialogFragment.kt | 100 -- .../disk_shader_cache/DiskShaderCacheProgress.kt | 51 - .../org/yuzu/yuzu_emu/features/DocumentProvider.kt | 341 ------- .../yuzu/yuzu_emu/features/input/NativeInput.kt | 416 -------- .../yuzu_emu/features/input/YuzuInputDevice.kt | 93 -- .../yuzu/yuzu_emu/features/input/YuzuVibrator.kt | 76 -- .../features/input/model/AnalogDirection.kt | 11 - .../yuzu_emu/features/input/model/ButtonName.kt | 19 - .../yuzu_emu/features/input/model/InputType.kt | 13 - .../yuzu_emu/features/input/model/NativeAnalog.kt | 14 - .../yuzu_emu/features/input/model/NativeButton.kt | 38 - .../yuzu_emu/features/input/model/NativeTrigger.kt | 10 - .../features/input/model/NpadStyleIndex.kt | 30 - .../yuzu_emu/features/input/model/PlayerInput.kt | 83 -- .../settings/model/AbstractBooleanSetting.kt | 9 - .../features/settings/model/AbstractByteSetting.kt | 9 - .../settings/model/AbstractFloatSetting.kt | 9 - .../features/settings/model/AbstractIntSetting.kt | 9 - .../features/settings/model/AbstractLongSetting.kt | 9 - .../features/settings/model/AbstractSetting.kt | 31 - .../settings/model/AbstractShortSetting.kt | 9 - .../settings/model/AbstractStringSetting.kt | 9 - .../features/settings/model/BooleanSetting.kt | 46 - .../features/settings/model/ByteSetting.kt | 25 - .../features/settings/model/FloatSetting.kt | 26 - .../yuzu_emu/features/settings/model/IntSetting.kt | 45 - .../features/settings/model/LongSetting.kt | 25 - .../yuzu_emu/features/settings/model/Settings.kt | 120 --- .../features/settings/model/ShortSetting.kt | 25 - .../features/settings/model/StringSetting.kt | 26 - .../settings/model/view/AnalogInputSetting.kt | 31 - .../settings/model/view/ButtonInputSetting.kt | 29 - .../settings/model/view/DateTimeSetting.kt | 20 - .../features/settings/model/view/HeaderSetting.kt | 13 - .../settings/model/view/InputProfileSetting.kt | 32 - .../features/settings/model/view/InputSetting.kt | 134 --- .../settings/model/view/IntSingleChoiceSetting.kt | 38 - .../settings/model/view/ModifierInputSetting.kt | 31 - .../settings/model/view/RunnableSetting.kt | 19 - .../features/settings/model/view/SettingsItem.kt | 391 -------- .../settings/model/view/SingleChoiceSetting.kt | 29 - .../features/settings/model/view/SliderSetting.kt | 42 - .../settings/model/view/StringInputSetting.kt | 22 - .../model/view/StringSingleChoiceSetting.kt | 35 - .../features/settings/model/view/SubmenuSetting.kt | 19 - .../features/settings/model/view/SwitchSetting.kt | 34 - .../features/settings/ui/InputDialogFragment.kt | 300 ------ .../features/settings/ui/InputProfileAdapter.kt | 68 -- .../settings/ui/InputProfileDialogFragment.kt | 148 --- .../settings/ui/NewInputProfileDialogFragment.kt | 79 -- .../features/settings/ui/SettingsActivity.kt | 171 ---- .../features/settings/ui/SettingsAdapter.kt | 434 -------- .../features/settings/ui/SettingsDialogFragment.kt | 301 ------ .../features/settings/ui/SettingsFragment.kt | 182 ---- .../settings/ui/SettingsFragmentPresenter.kt | 975 ------------------ .../features/settings/ui/SettingsSearchFragment.kt | 183 ---- .../features/settings/ui/SettingsViewModel.kt | 112 --- .../settings/ui/viewholder/DateTimeViewHolder.kt | 54 - .../settings/ui/viewholder/HeaderViewHolder.kt | 30 - .../ui/viewholder/InputProfileViewHolder.kt | 34 - .../settings/ui/viewholder/InputViewHolder.kt | 60 -- .../settings/ui/viewholder/RunnableViewHolder.kt | 50 - .../settings/ui/viewholder/SettingViewHolder.kt | 54 - .../ui/viewholder/SingleChoiceViewHolder.kt | 91 -- .../settings/ui/viewholder/SliderViewHolder.kt | 50 - .../ui/viewholder/StringInputViewHolder.kt | 45 - .../settings/ui/viewholder/SubmenuViewHolder.kt | 46 - .../ui/viewholder/SwitchSettingViewHolder.kt | 51 - .../features/settings/utils/SettingsFile.kt | 29 - .../org/yuzu/yuzu_emu/fragments/AboutFragment.kt | 124 --- .../fragments/AddGameFolderDialogFragment.kt | 56 -- .../org/yuzu/yuzu_emu/fragments/AddonsFragment.kt | 205 ---- .../yuzu_emu/fragments/AppletLauncherFragment.kt | 106 -- .../fragments/CabinetLauncherDialogFragment.kt | 41 - .../ContentTypeSelectionDialogFragment.kt | 68 -- .../yuzu_emu/fragments/CoreErrorDialogFragment.kt | 47 - .../yuzu_emu/fragments/DriverManagerFragment.kt | 199 ---- .../fragments/DriversLoadingDialogFragment.kt | 50 - .../yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt | 87 -- .../yuzu/yuzu_emu/fragments/EmulationFragment.kt | 1048 ------------------- .../GameFolderPropertiesDialogFragment.kt | 78 -- .../yuzu/yuzu_emu/fragments/GameFoldersFragment.kt | 116 --- .../yuzu/yuzu_emu/fragments/GameInfoFragment.kt | 179 ---- .../yuzu_emu/fragments/GamePropertiesFragment.kt | 424 -------- .../yuzu_emu/fragments/HomeSettingsFragment.kt | 437 -------- .../yuzu/yuzu_emu/fragments/InstallableFragment.kt | 323 ------ .../yuzu_emu/fragments/LaunchGameDialogFragment.kt | 61 -- .../fragments/LicenseBottomSheetDialogFragment.kt | 59 -- .../yuzu/yuzu_emu/fragments/LicensesFragment.kt | 132 --- .../yuzu_emu/fragments/MessageDialogFragment.kt | 195 ---- .../fragments/PermissionDeniedDialogFragment.kt | 38 - .../yuzu_emu/fragments/ProgressDialogFragment.kt | 148 --- .../fragments/ResetSettingsDialogFragment.kt | 30 - .../org/yuzu/yuzu_emu/fragments/SearchFragment.kt | 218 ---- .../org/yuzu/yuzu_emu/fragments/SetupFragment.kt | 396 -------- .../fragments/SetupWarningDialogFragment.kt | 86 -- .../yuzu_emu/layout/AutofitGridLayoutManager.kt | 63 -- .../java/org/yuzu/yuzu_emu/model/AddonViewModel.kt | 97 -- .../main/java/org/yuzu/yuzu_emu/model/Applet.kt | 55 - .../main/java/org/yuzu/yuzu_emu/model/Driver.kt | 27 - .../org/yuzu/yuzu_emu/model/DriverViewModel.kt | 196 ---- .../org/yuzu/yuzu_emu/model/EmulationViewModel.kt | 76 -- .../src/main/java/org/yuzu/yuzu_emu/model/Game.kt | 103 -- .../main/java/org/yuzu/yuzu_emu/model/GameDir.kt | 13 - .../java/org/yuzu/yuzu_emu/model/GameProperties.kt | 36 - .../yuzu/yuzu_emu/model/GameVerificationResult.kt | 15 - .../java/org/yuzu/yuzu_emu/model/GamesViewModel.kt | 186 ---- .../java/org/yuzu/yuzu_emu/model/HomeSetting.kt | 18 - .../java/org/yuzu/yuzu_emu/model/HomeViewModel.kt | 76 -- .../java/org/yuzu/yuzu_emu/model/InstallResult.kt | 15 - .../java/org/yuzu/yuzu_emu/model/Installable.kt | 13 - .../main/java/org/yuzu/yuzu_emu/model/License.kt | 16 - .../yuzu/yuzu_emu/model/MessageDialogViewModel.kt | 16 - .../org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt | 11 - .../src/main/java/org/yuzu/yuzu_emu/model/Patch.kt | 16 - .../main/java/org/yuzu/yuzu_emu/model/PatchType.kt | 14 - .../java/org/yuzu/yuzu_emu/model/SelectableItem.kt | 9 - .../main/java/org/yuzu/yuzu_emu/model/SetupPage.kt | 29 - .../java/org/yuzu/yuzu_emu/model/TaskViewModel.kt | 83 -- .../java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt | 1049 -------------------- .../yuzu_emu/overlay/InputOverlayDrawableButton.kt | 151 --- .../yuzu_emu/overlay/InputOverlayDrawableDpad.kt | 266 ----- .../overlay/InputOverlayDrawableJoystick.kt | 292 ------ .../yuzu/yuzu_emu/overlay/model/OverlayControl.kt | 188 ---- .../yuzu_emu/overlay/model/OverlayControlData.kt | 19 - .../overlay/model/OverlayControlDefault.kt | 13 - .../yuzu/yuzu_emu/overlay/model/OverlayLayout.kt | 10 - .../java/org/yuzu/yuzu_emu/ui/GamesFragment.kt | 160 --- .../java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt | 692 ------------- .../org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt | 11 - .../main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt | 8 - .../yuzu/yuzu_emu/utils/DirectoryInitialization.kt | 213 ---- .../java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt | 137 --- .../main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt | 503 ---------- .../java/org/yuzu/yuzu_emu/utils/GameHelper.kt | 152 --- .../java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt | 109 -- .../java/org/yuzu/yuzu_emu/utils/GameMetadata.kt | 22 - .../org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt | 229 ----- .../org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt | 119 --- .../java/org/yuzu/yuzu_emu/utils/InputHandler.kt | 94 -- .../java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt | 25 - .../java/org/yuzu/yuzu_emu/utils/LifecycleUtils.kt | 38 - .../src/main/java/org/yuzu/yuzu_emu/utils/Log.kt | 31 - .../java/org/yuzu/yuzu_emu/utils/MemoryUtil.kt | 111 --- .../java/org/yuzu/yuzu_emu/utils/NativeConfig.kt | 186 ---- .../main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt | 171 ---- .../java/org/yuzu/yuzu_emu/utils/ParamPackage.kt | 141 --- .../java/org/yuzu/yuzu_emu/utils/PreferenceUtil.kt | 37 - .../org/yuzu/yuzu_emu/utils/SerializableHelper.kt | 44 - .../java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt | 105 -- .../main/java/org/yuzu/yuzu_emu/utils/ViewUtils.kt | 93 -- .../yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt | 18 - .../yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt | 64 -- .../app/src/main/res/drawable/ic_citron.xml | 22 + .../app/src/main/res/drawable/ic_citron_full.xml | 12 + .../app/src/main/res/drawable/ic_citron_title.xml | 24 + src/android/app/src/main/res/drawable/ic_yuzu.xml | 22 - .../app/src/main/res/drawable/ic_yuzu_full.xml | 12 - .../app/src/main/res/drawable/ic_yuzu_title.xml | 24 - .../src/main/res/values-night/citron_colors.xml | 37 + .../app/src/main/res/values-night/yuzu_colors.xml | 37 - .../app/src/main/res/values/citron_colors.xml | 37 + .../app/src/main/res/values/yuzu_colors.xml | 37 - 358 files changed, 20133 insertions(+), 20133 deletions(-) create mode 100644 src/android/app/src/ea/res/drawable/ic_citron.xml create mode 100644 src/android/app/src/ea/res/drawable/ic_citron_full.xml create mode 100644 src/android/app/src/ea/res/drawable/ic_citron_title.xml delete mode 100644 src/android/app/src/ea/res/drawable/ic_yuzu.xml delete mode 100644 src/android/app/src/ea/res/drawable/ic_yuzu_full.xml delete mode 100644 src/android/app/src/ea/res/drawable/ic_yuzu_title.xml create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/NativeLibrary.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/YuzuApplication.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/activities/EmulationActivity.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractDiffAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractListAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractSingleSelectionList.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AddonAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AppletAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/DriverAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/FolderAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GameAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GamePropertiesAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/HomeSettingAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/InstallableAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/LicenseAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/adapters/SetupAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/DocumentProvider.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/NativeInput.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuInputDevice.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuVibrator.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/AnalogDirection.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/ButtonName.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/InputType.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeAnalog.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeButton.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeTrigger.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NpadStyleIndex.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/PlayerInput.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractByteSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractFloatSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractIntSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractLongSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractShortSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractStringSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/BooleanSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ByteSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/FloatSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/IntSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/LongSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/Settings.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ShortSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/StringSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/DateTimeSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/HeaderSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputProfileSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/RunnableSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SettingsItem.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SliderSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringInputSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SubmenuSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SwitchSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsActivity.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsAdapter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/utils/SettingsFile.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AboutFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddGameFolderDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddonsFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AppletLauncherFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CoreErrorDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriverManagerFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriversLoadingDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EarlyAccessFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EmulationFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFoldersFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameInfoFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GamePropertiesFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/HomeSettingsFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/InstallableFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LaunchGameDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicensesFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/MessageDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ProgressDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ResetSettingsDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SearchFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupWarningDialogFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/layout/AutofitGridLayoutManager.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/AddonViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/Applet.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/Driver.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/DriverViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/EmulationViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/Game.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/GameDir.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/GameProperties.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/GameVerificationResult.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/GamesViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeSetting.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/InstallResult.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/Installable.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/License.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/MessageDialogViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/MinimalDocumentFile.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/Patch.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/PatchType.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/SelectableItem.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/SetupPage.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/model/TaskViewModel.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlay.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableButton.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableDpad.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControl.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlData.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlDefault.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayLayout.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/ui/GamesFragment.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/MainActivity.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/ThemeProvider.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/AddonUtil.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/DirectoryInitialization.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/DocumentsTree.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/FileUtil.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameHelper.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameIconUtils.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameMetadata.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverHelper.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverMetadata.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/InputHandler.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/InsetsHelper.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/LifecycleUtils.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/Log.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/MemoryUtil.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/NativeConfig.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/NfcReader.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/ParamPackage.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/PreferenceUtil.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/SerializableHelper.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/ThemeHelper.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/utils/ViewUtils.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/viewholder/AbstractViewHolder.kt create mode 100644 src/android/app/src/main/java/org/citron/yuzu_emu/views/FixedRatioSurfaceView.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractByteSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractLongSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractShortSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ByteSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/LongSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ShortSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringInputSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CoreErrorDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Applet.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/EmulationViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameVerificationResult.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControl.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlData.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlDefault.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayLayout.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameMetadata.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/LifecycleUtils.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/MemoryUtil.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PreferenceUtil.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ViewUtils.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt delete mode 100644 src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt create mode 100644 src/android/app/src/main/res/drawable/ic_citron.xml create mode 100644 src/android/app/src/main/res/drawable/ic_citron_full.xml create mode 100644 src/android/app/src/main/res/drawable/ic_citron_title.xml delete mode 100644 src/android/app/src/main/res/drawable/ic_yuzu.xml delete mode 100644 src/android/app/src/main/res/drawable/ic_yuzu_full.xml delete mode 100644 src/android/app/src/main/res/drawable/ic_yuzu_title.xml create mode 100644 src/android/app/src/main/res/values-night/citron_colors.xml delete mode 100644 src/android/app/src/main/res/values-night/yuzu_colors.xml create mode 100644 src/android/app/src/main/res/values/citron_colors.xml delete mode 100644 src/android/app/src/main/res/values/yuzu_colors.xml (limited to 'src/android') diff --git a/src/android/app/src/ea/res/drawable/ic_citron.xml b/src/android/app/src/ea/res/drawable/ic_citron.xml new file mode 100644 index 000000000..deb8ba53f --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_citron.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_citron_full.xml b/src/android/app/src/ea/res/drawable/ic_citron_full.xml new file mode 100644 index 000000000..4ef472876 --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_citron_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_citron_title.xml b/src/android/app/src/ea/res/drawable/ic_citron_title.xml new file mode 100644 index 000000000..29d0cfced --- /dev/null +++ b/src/android/app/src/ea/res/drawable/ic_citron_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu.xml b/src/android/app/src/ea/res/drawable/ic_yuzu.xml deleted file mode 100644 index deb8ba53f..000000000 --- a/src/android/app/src/ea/res/drawable/ic_yuzu.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml deleted file mode 100644 index 4ef472876..000000000 --- a/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml deleted file mode 100644 index 29d0cfced..000000000 --- a/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/NativeLibrary.kt new file mode 100644 index 000000000..02a20dacf --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/NativeLibrary.kt @@ -0,0 +1,462 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.content.DialogInterface +import android.net.Uri +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 com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.lang.ref.WeakReference +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.fragments.CoreErrorDialogFragment +import org.yuzu.yuzu_emu.utils.DocumentsTree +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.Log +import org.yuzu.yuzu_emu.model.InstallResult +import org.yuzu.yuzu_emu.model.Patch +import org.yuzu.yuzu_emu.model.GameVerificationResult + +/** + * Class which contains methods that interact + * with the native side of the Yuzu code. + */ +object NativeLibrary { + @JvmField + var sEmulationActivity = WeakReference(null) + + init { + try { + System.loadLibrary("yuzu-android") + } catch (ex: UnsatisfiedLinkError) { + error("[NativeLibrary] $ex") + } + } + + @Keep + @JvmStatic + fun openContentUri(path: String?, openmode: String?): Int { + return if (DocumentsTree.isNativePath(path!!)) { + YuzuApplication.documentsTree!!.openContentUri(path, openmode) + } else { + FileUtil.openContentUri(path, openmode) + } + } + + @Keep + @JvmStatic + fun getSize(path: String?): Long { + return if (DocumentsTree.isNativePath(path!!)) { + YuzuApplication.documentsTree!!.getFileSize(path) + } else { + FileUtil.getFileSize(path) + } + } + + @Keep + @JvmStatic + fun exists(path: String?): Boolean { + return if (DocumentsTree.isNativePath(path!!)) { + YuzuApplication.documentsTree!!.exists(path) + } else { + FileUtil.exists(path, suppressLog = true) + } + } + + @Keep + @JvmStatic + fun isDirectory(path: String?): Boolean { + return if (DocumentsTree.isNativePath(path!!)) { + YuzuApplication.documentsTree!!.isDirectory(path) + } else { + FileUtil.isDirectory(path) + } + } + + @Keep + @JvmStatic + fun getParentDirectory(path: String): String = + if (DocumentsTree.isNativePath(path)) { + YuzuApplication.documentsTree!!.getParentDirectory(path) + } else { + path + } + + @Keep + @JvmStatic + fun getFilename(path: String): String = + if (DocumentsTree.isNativePath(path)) { + YuzuApplication.documentsTree!!.getFilename(path) + } else { + FileUtil.getFilename(Uri.parse(path)) + } + + external fun setAppDirectory(directory: String) + + /** + * Installs a nsp or xci file to nand + * @param filename String representation of file uri + * @return int representation of [InstallResult] + */ + external fun installFileToNand( + filename: String, + callback: (max: Long, progress: Long) -> Boolean + ): Int + + external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean + + external fun initializeGpuDriver( + hookLibDir: String?, + customDriverDir: String?, + customDriverName: String?, + fileRedirectDir: String? + ) + + external fun reloadKeys(): Boolean + + external fun initializeSystem(reload: Boolean) + + /** + * Begins emulation. + */ + external fun run(path: String?, programIndex: Int, frontendInitiated: Boolean) + + // Surface Handling + external fun surfaceChanged(surf: Surface?) + + external fun surfaceDestroyed() + + /** + * Unpauses emulation from a paused state. + */ + external fun unpauseEmulation() + + /** + * Pauses emulation. + */ + external fun pauseEmulation() + + /** + * Stops emulation. + */ + external fun stopEmulation() + + /** + * Returns true if emulation is running (or is paused). + */ + external fun isRunning(): Boolean + + /** + * Returns true if emulation is paused. + */ + external fun isPaused(): Boolean + + /** + * Returns the performance stats for the current game + */ + external fun getPerfStats(): DoubleArray + + /** + * Returns the current CPU backend. + */ + external fun getCpuBackend(): String + + /** + * Returns the current GPU Driver. + */ + external fun getGpuDriver(): String + + external fun applySettings() + + external fun logSettings() + + enum class CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown + } + + var coreErrorAlertResult = false + 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, "coreError") + } + + /** + * Handles a core error. + * + * @return true: continue; false: abort + */ + 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 { onCoreErrorImpl(title, message) } + + // Wait for the lock to notify that it is complete. + synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } + + return coreErrorAlertResult + } + + @Keep + @JvmStatic + fun exitEmulationActivity(resultCode: Int) { + val Success = 0 + val ErrorNotInitialized = 1 + val ErrorGetLoader = 2 + val ErrorSystemFiles = 3 + val ErrorSharedFont = 4 + val ErrorVideoCore = 5 + val ErrorUnknown = 6 + val ErrorLoader = 7 + + val captionId: Int + var descriptionId: Int + when (resultCode) { + ErrorVideoCore -> { + captionId = R.string.loader_error_video_core + descriptionId = R.string.loader_error_video_core_description + } + + else -> { + captionId = R.string.loader_error_encrypted + descriptionId = R.string.loader_error_encrypted_roms_description + if (!reloadKeys()) { + descriptionId = R.string.loader_error_encrypted_keys_description + } + } + } + + val emulationActivity = sEmulationActivity.get() + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.") + return + } + + val builder = MaterialAlertDialogBuilder(emulationActivity) + .setTitle(captionId) + .setMessage( + Html.fromHtml( + emulationActivity.getString(descriptionId), + Html.FROM_HTML_MODE_LEGACY + ) + ) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + emulationActivity.finish() + } + .setOnDismissListener { emulationActivity.finish() } + emulationActivity.runOnUiThread { + val alert = builder.create() + alert.show() + (alert.findViewById(android.R.id.message) as TextView).movementMethod = + LinkMovementMethod.getInstance() + } + } + + fun setEmulationActivity(emulationActivity: EmulationActivity?) { + Log.debug("[NativeLibrary] Registering EmulationActivity.") + sEmulationActivity = WeakReference(emulationActivity) + } + + fun clearEmulationActivity() { + Log.debug("[NativeLibrary] Unregistering EmulationActivity.") + sEmulationActivity.clear() + } + + @Keep + @JvmStatic + fun onEmulationStarted() { + sEmulationActivity.get()!!.onEmulationStarted() + } + + @Keep + @JvmStatic + fun onEmulationStopped(status: Int) { + sEmulationActivity.get()!!.onEmulationStopped(status) + } + + @Keep + @JvmStatic + fun onProgramChanged(programIndex: Int) { + sEmulationActivity.get()!!.onProgramChanged(programIndex) + } + + /** + * Logs the Yuzu version, Android version and, CPU. + */ + external fun logDeviceInfo() + + /** + * Submits inline keyboard text. Called on input for buttons that result text. + * @param text Text to submit to the inline software keyboard implementation. + */ + external fun submitInlineKeyboardText(text: String?) + + /** + * Submits inline keyboard input. Used to indicate keys pressed that are not text. + * @param key_code Android Key Code associated with the keyboard input. + */ + external fun submitInlineKeyboardInput(key_code: Int) + + /** + * Creates a generic user directory if it doesn't exist already + */ + external fun initializeEmptyUserDirectory() + + /** + * Gets the launch path for a given applet. It is the caller's responsibility to also + * set the system's current applet ID before trying to launch the nca given by this function. + * + * @param id The applet entry ID + * @return The applet's launch path + */ + external fun getAppletLaunchPath(id: Long): String + + /** + * Sets the system's current applet ID before launching. + * + * @param appletId One of the ids in the Service::AM::Applets::AppletId enum + */ + external fun setCurrentAppletId(appletId: Int) + + /** + * Sets the cabinet mode for launching the cabinet applet. + * + * @param cabinetMode One of the modes that corresponds to the enum in Service::NFP::CabinetMode + */ + external fun setCabinetMode(cabinetMode: Int) + + /** + * Checks whether NAND contents are available and valid. + * + * @return 'true' if firmware is available + */ + external fun isFirmwareAvailable(): Boolean + + /** + * Checks the PatchManager for any addons that are available + * + * @param path Path to game file. Can be a [Uri]. + * @param programId String representation of a game's program ID + * @return Array of available patches + */ + external fun getPatchesForFile(path: String, programId: String): Array? + + /** + * Removes an update for a given [programId] + * @param programId String representation of a game's program ID + */ + external fun removeUpdate(programId: String) + + /** + * Removes all DLC for a [programId] + * @param programId String representation of a game's program ID + */ + external fun removeDLC(programId: String) + + /** + * Removes a mod installed for a given [programId] + * @param programId String representation of a game's program ID + * @param name The name of a mod as given by [getPatchesForFile]. This corresponds with the name + * of the mod's directory in a game's load folder. + */ + external fun removeMod(programId: String, name: String) + + /** + * Verifies all installed content + * @param callback UI callback for verification progress. Return true in the callback to cancel. + * @return Array of content that failed verification. Successful if empty. + */ + external fun verifyInstalledContents( + callback: (max: Long, progress: Long) -> Boolean + ): Array + + /** + * Verifies the contents of a game + * @param path String path to a game + * @param callback UI callback for verification progress. Return true in the callback to cancel. + * @return Int that is meant to be converted to a [GameVerificationResult] + */ + external fun verifyGameContents( + path: String, + callback: (max: Long, progress: Long) -> Boolean + ): Int + + /** + * Gets the save location for a specific game + * + * @param programId String representation of a game's program ID + * @return Save data path that may not exist yet + */ + external fun getSavePath(programId: String): String + + /** + * Gets the root save directory for the default profile as either + * /user/save/account/ or /user/save/000...000/ + * + * @param future If true, returns the /user/save/account/... directory + * @return Save data path that may not exist yet + */ + external fun getDefaultProfileSaveDataRoot(future: Boolean): String + + /** + * Adds a file to the manual filesystem provider in our EmulationSession instance + * @param path Path to the file we're adding. Can be a string representation of a [Uri] or + * a normal path + */ + external fun addFileToFilesystemProvider(path: String) + + /** + * Clears all files added to the manual filesystem provider in our EmulationSession instance + */ + external fun clearFilesystemProvider() + + /** + * Checks if all necessary keys are present for decryption + */ + external fun areKeysPresent(): Boolean +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/YuzuApplication.kt new file mode 100644 index 000000000..72943f33e --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/YuzuApplication.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import org.yuzu.yuzu_emu.features.input.NativeInput +import java.io.File +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.DocumentsTree +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.Log + +fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir + +class YuzuApplication : Application() { + private fun createNotificationChannels() { + val noticeChannel = NotificationChannel( + getString(R.string.notice_notification_channel_id), + getString(R.string.notice_notification_channel_name), + NotificationManager.IMPORTANCE_HIGH + ) + noticeChannel.description = getString(R.string.notice_notification_channel_description) + noticeChannel.setSound(null, null) + + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(noticeChannel) + } + + override fun onCreate() { + super.onCreate() + application = this + documentsTree = DocumentsTree() + DirectoryInitialization.start() + GpuDriverHelper.initializeDriverParameters() + NativeInput.reloadInputDevices() + NativeLibrary.logDeviceInfo() + Log.logDeviceInfo() + + createNotificationChannels() + } + + companion object { + var documentsTree: DocumentsTree? = null + lateinit var application: YuzuApplication + + val appContext: Context + get() = application.applicationContext + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/activities/EmulationActivity.kt new file mode 100644 index 000000000..c962558a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/activities/EmulationActivity.kt @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.activities + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.graphics.Rect +import android.graphics.drawable.Icon +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.preference.PreferenceManager +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.ActivityEmulationBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.EmulationViewModel +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.Log +import org.yuzu.yuzu_emu.utils.MemoryUtil +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.NfcReader +import org.yuzu.yuzu_emu.utils.ParamPackage +import org.yuzu.yuzu_emu.utils.ThemeHelper +import java.text.NumberFormat +import kotlin.math.roundToInt + +class EmulationActivity : AppCompatActivity(), SensorEventListener { + private lateinit var binding: ActivityEmulationBinding + + var isActivityRecreated = false + private lateinit var nfcReader: NfcReader + + private val gyro = FloatArray(3) + private val accel = FloatArray(3) + private var motionTimestamp: Long = 0 + private var flipMotionOrientation: Boolean = false + + private val actionPause = "ACTION_EMULATOR_PAUSE" + private val actionPlay = "ACTION_EMULATOR_PLAY" + private val actionMute = "ACTION_EMULATOR_MUTE" + private val actionUnmute = "ACTION_EMULATOR_UNMUTE" + + private val emulationViewModel: EmulationViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + Log.gameLaunched = true + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + InputHandler.updateControllerData() + val players = NativeConfig.getInputSettings(true) + var hasConfiguredControllers = false + players.forEach { + if (it.hasMapping()) { + hasConfiguredControllers = true + } + } + if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) { + var params: ParamPackage? = null + for (controller in InputHandler.registeredControllers) { + if (controller.get("port", -1) == 0) { + params = controller + break + } + } + + if (params != null) { + NativeInput.updateMappingsWithDefault( + 0, + params, + params.get("display", getString(R.string.unknown)) + ) + NativeConfig.saveGlobalConfig() + } + } + + binding = ActivityEmulationBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) + + isActivityRecreated = savedInstanceState != null + + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive() + + window.decorView.setBackgroundColor(getColor(android.R.color.black)) + + nfcReader = NfcReader(this) + nfcReader.initialize() + + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) { + if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) { + Toast.makeText( + this, + getString( + R.string.device_memory_inadequate, + MemoryUtil.getDeviceRAM(), + getString( + R.string.memory_formatted, + NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY), + getString(R.string.memory_gigabyte) + ) + ), + Toast.LENGTH_LONG + ).show() + preferences.edit() + .putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true) + .apply() + } + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + // Special case, we do not support multiline input, dismiss the keyboard. + val overlayView: View = + this.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager + im.hideSoftInputFromWindow(overlayView.windowToken, 0) + } else { + val textChar = event.unicodeChar + if (textChar == 0) { + // No text, button input. + NativeLibrary.submitInlineKeyboardInput(keyCode) + } else { + // Text submitted. + NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString()) + } + } + } + return super.onKeyDown(keyCode, event) + } + + override fun onResume() { + super.onResume() + nfcReader.startScanning() + startMotionSensorListener() + InputHandler.updateControllerData() + + buildPictureInPictureParams() + } + + override fun onPause() { + super.onPause() + nfcReader.stopScanning() + stopMotionSensorListener() + } + + override fun onUserLeaveHint() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + enterPictureInPictureMode(pictureInPictureParamsBuilder.build()) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + nfcReader.onNewIntent(intent) + InputHandler.updateControllerData() + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchKeyEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchKeyEvent(event) + } + + return InputHandler.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return super.dispatchGenericMotionEvent(event) + } + + if (emulationViewModel.drawerOpen.value) { + return super.dispatchGenericMotionEvent(event) + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.actionMasked == MotionEvent.ACTION_CANCEL) { + return true + } + + return InputHandler.dispatchGenericMotionEvent(event) + } + + override fun onSensorChanged(event: SensorEvent) { + val rotation = this.display?.rotation + if (rotation == Surface.ROTATION_90) { + flipMotionOrientation = true + } + if (rotation == Surface.ROTATION_270) { + flipMotionOrientation = false + } + + if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { + if (flipMotionOrientation) { + accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH + } else { + accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH + accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH + } + accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH + } + if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { + // Investigate why sensor value is off by 6x + if (flipMotionOrientation) { + gyro[0] = -event.values[1] / 6.0f + gyro[1] = event.values[0] / 6.0f + } else { + gyro[0] = event.values[1] / 6.0f + gyro[1] = -event.values[0] / 6.0f + } + gyro[2] = event.values[2] / 6.0f + } + + // Only update state on accelerometer data + if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { + return + } + val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 + motionTimestamp = event.timestamp + NativeInput.onDeviceMotionEvent( + NativeInput.Player1Device, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + NativeInput.onDeviceMotionEvent( + NativeInput.ConsoleDevice, + deltaTimestamp, + gyro[0], + gyro[1], + gyro[2], + accel[0], + accel[1], + accel[2] + ) + } + + override fun onAccuracyChanged(sensor: Sensor, i: Int) {} + + private fun enableFullscreenImmersive() { + WindowCompat.setDecorFitsSystemWindows(window, false) + + WindowInsetsControllerCompat(window, window.decorView).let { controller -> + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder(): + PictureInPictureParams.Builder { + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + return this.apply { aspectRatio?.let { setAspectRatio(it) } } + } + + private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder(): + PictureInPictureParams.Builder { + val pictureInPictureActions: MutableList = mutableListOf() + val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + if (NativeLibrary.isPaused()) { + val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play) + val playPendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_play, + Intent(actionPlay), + pendingFlags + ) + val playRemoteAction = RemoteAction( + playIcon, + getString(R.string.play), + getString(R.string.play), + playPendingIntent + ) + pictureInPictureActions.add(playRemoteAction) + } else { + val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause) + val pausePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_pause, + Intent(actionPause), + pendingFlags + ) + val pauseRemoteAction = RemoteAction( + pauseIcon, + getString(R.string.pause), + getString(R.string.pause), + pausePendingIntent + ) + pictureInPictureActions.add(pauseRemoteAction) + } + + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + val unmuteIcon = Icon.createWithResource( + this@EmulationActivity, + R.drawable.ic_pip_unmute + ) + val unmutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_unmute, + Intent(actionUnmute), + pendingFlags + ) + val unmuteRemoteAction = RemoteAction( + unmuteIcon, + getString(R.string.unmute), + getString(R.string.unmute), + unmutePendingIntent + ) + pictureInPictureActions.add(unmuteRemoteAction) + } else { + val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute) + val mutePendingIntent = PendingIntent.getBroadcast( + this@EmulationActivity, + R.drawable.ic_pip_mute, + Intent(actionMute), + pendingFlags + ) + val muteRemoteAction = RemoteAction( + muteIcon, + getString(R.string.mute), + getString(R.string.mute), + mutePendingIntent + ) + pictureInPictureActions.add(muteRemoteAction) + } + + return this.apply { setActions(pictureInPictureActions) } + } + + fun buildPictureInPictureParams() { + val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() + .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val isEmulationActive = emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + pictureInPictureParamsBuilder.setAutoEnterEnabled( + BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive + ) + } + setPictureInPictureParams(pictureInPictureParamsBuilder.build()) + } + + private var pictureInPictureReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { + if (intent.action == actionPlay) { + if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation() + } else if (intent.action == actionPause) { + if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation() + } + if (intent.action == actionUnmute) { + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } else if (intent.action == actionMute) { + if (!BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(true) + } + } + buildPictureInPictureParams() + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + IntentFilter().apply { + addAction(actionPause) + addAction(actionPlay) + addAction(actionMute) + addAction(actionUnmute) + }.also { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED) + } else { + registerReceiver(pictureInPictureReceiver, it) + } + } + } else { + try { + unregisterReceiver(pictureInPictureReceiver) + } catch (ignored: Exception) { + } + // Always resume audio, since there is no UI button + if (BooleanSetting.AUDIO_MUTED.getBoolean()) { + BooleanSetting.AUDIO_MUTED.setBoolean(false) + } + } + } + + fun onEmulationStarted() { + emulationViewModel.setEmulationStarted(true) + } + + fun onEmulationStopped(status: Int) { + if (status == 0 && emulationViewModel.programChanged.value == -1) { + finish() + } + emulationViewModel.setEmulationStopped(true) + } + + fun onProgramChanged(programIndex: Int) { + emulationViewModel.setProgramChanged(programIndex) + } + + private fun startMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME) + sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME) + } + + private fun stopMotionSensorListener() { + val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + sensorManager.unregisterListener(this, gyroSensor) + sensorManager.unregisterListener(this, accelSensor) + } + + companion object { + const val EXTRA_SELECTED_GAME = "SelectedGame" + + fun launch(activity: AppCompatActivity, game: Game) { + val launcher = Intent(activity, EmulationActivity::class.java) + launcher.putExtra(EXTRA_SELECTED_GAME, game) + activity.startActivity(launcher) + } + + private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean { + if (view == null) { + return true + } + val viewBounds = Rect() + view.getGlobalVisibleRect(viewBounds) + return !viewBounds.contains(x.roundToInt(), y.roundToInt()) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractDiffAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractDiffAdapter.kt new file mode 100644 index 000000000..0ab1b46c3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractDiffAdapter.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder +import androidx.recyclerview.widget.RecyclerView + +/** + * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate + * code used in every [RecyclerView]. + * Type assigned to [Model] must inherit from [Object] in order to be compared properly. + * @param exact Decides whether each item will be compared by reference or by their contents + */ +abstract class AbstractDiffAdapter>( + exact: Boolean = true +) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + if (exact) { + return oldItem === newItem + } + return oldItem == newItem + } + + @SuppressLint("DiffUtilEquals") + override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { + return oldItem == newItem + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractListAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractListAdapter.kt new file mode 100644 index 000000000..3dfee3d0c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractListAdapter.kt @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.annotation.SuppressLint +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +/** + * Generic list class meant to take care of basic lists + * @param currentList The list to show initially + */ +abstract class AbstractListAdapter>( + open var currentList: List +) : RecyclerView.Adapter() { + override fun onBindViewHolder(holder: Holder, position: Int) = + holder.bind(currentList[position]) + + override fun getItemCount(): Int = currentList.size + + /** + * Adds an item to [currentList] and notifies the underlying adapter of the change. If no parameter + * is passed in for position, [item] is added to the end of the list. Invokes [callback] last. + * @param item The item to add to the list + * @param position Index where [item] will be added + * @param callback Lambda that's called at the end of the list changes and has the added list + * position passed in as a parameter + */ + open fun addItem(item: Model, position: Int = -1, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + val positionToUpdate: Int + if (position == -1) { + newList.add(item) + currentList = newList + positionToUpdate = currentList.size - 1 + } else { + newList.add(position, item) + currentList = newList + positionToUpdate = position + } + onItemAdded(positionToUpdate, callback) + } + + protected fun onItemAdded(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemInserted(position) + callback?.invoke(position) + } + + /** + * Replaces the [item] at [position] in the [currentList] and notifies the underlying adapter + * of the change. Invokes [callback] last. + * @param item New list item + * @param position Index where [item] will replace the existing list item + * @param callback Lambda that's called at the end of the list changes and has the changed list + * position passed in as a parameter + */ + fun changeItem(item: Model, position: Int, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + newList[position] = item + currentList = newList + onItemChanged(position, callback) + } + + protected fun onItemChanged(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemChanged(position) + callback?.invoke(position) + } + + /** + * Removes the list item at [position] in [currentList] and notifies the underlying adapter + * of the change. Invokes [callback] last. + * @param position Index where the list item will be removed + * @param callback Lambda that's called at the end of the list changes and has the removed list + * position passed in as a parameter + */ + fun removeItem(position: Int, callback: ((position: Int) -> Unit)? = null) { + val newList = currentList.toMutableList() + newList.removeAt(position) + currentList = newList + onItemRemoved(position, callback) + } + + protected fun onItemRemoved(position: Int, callback: ((Int) -> Unit)? = null) { + notifyItemRemoved(position) + callback?.invoke(position) + } + + /** + * Replaces [currentList] with [newList] and notifies the underlying adapter of the change. + * @param newList The new list to replace [currentList] + */ + @SuppressLint("NotifyDataSetChanged") + open fun replaceList(newList: List) { + currentList = newList + notifyDataSetChanged() + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractSingleSelectionList.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractSingleSelectionList.kt new file mode 100644 index 000000000..52163f9d7 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AbstractSingleSelectionList.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import org.yuzu.yuzu_emu.model.SelectableItem +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +/** + * Generic list class meant to take care of single selection UI updates + * @param currentList The list to show initially + * @param defaultSelection The default selection to use if no list items are selected by + * [SelectableItem.selected] or if the currently selected item is removed from the list + */ +abstract class AbstractSingleSelectionList< + Model : SelectableItem, + Holder : AbstractViewHolder + >( + final override var currentList: List, + private val defaultSelection: DefaultSelection = DefaultSelection.Start +) : AbstractListAdapter(currentList) { + var selectedItem = getDefaultSelection() + + init { + findSelectedItem() + } + + /** + * Changes the selection state of the [SelectableItem] that was selected and the previously selected + * item and notifies the underlying adapter of the change for those items. Invokes [callback] last. + * Does nothing if [position] is the same as the currently selected item. + * @param position Index of the item that was selected + * @param callback Lambda that's called at the end of the list changes and has the selected list + * position passed in as a parameter + */ + fun selectItem(position: Int, callback: ((position: Int) -> Unit)? = null) { + if (position == selectedItem) { + return + } + + val previouslySelectedItem = selectedItem + selectedItem = position + if (currentList.indices.contains(selectedItem)) { + currentList[selectedItem].onSelectionStateChanged(true) + } + if (currentList.indices.contains(previouslySelectedItem)) { + currentList[previouslySelectedItem].onSelectionStateChanged(false) + } + onItemChanged(previouslySelectedItem) + onItemChanged(selectedItem) + callback?.invoke(position) + } + + /** + * Removes a given item from the list and notifies the underlying adapter of the change. If the + * currently selected item was the item that was removed, the item at the position provided + * by [defaultSelection] will be made the new selection. Invokes [callback] last. + * @param position Index of the item that was removed + * @param callback Lambda that's called at the end of the list changes and has the removed and + * selected list positions passed in as parameters + */ + fun removeSelectableItem( + position: Int, + callback: ((removedPosition: Int, selectedPosition: Int) -> Unit)? + ) { + removeItem(position) + if (position == selectedItem) { + selectedItem = getDefaultSelection() + currentList[selectedItem].onSelectionStateChanged(true) + onItemChanged(selectedItem) + } else if (position < selectedItem) { + selectedItem-- + } + callback?.invoke(position, selectedItem) + } + + override fun addItem(item: Model, position: Int, callback: ((Int) -> Unit)?) { + super.addItem(item, position, callback) + if (position <= selectedItem && position != -1) { + selectedItem++ + } + } + + override fun replaceList(newList: List) { + super.replaceList(newList) + findSelectedItem() + } + + private fun findSelectedItem() { + for (i in currentList.indices) { + if (currentList[i].selected) { + selectedItem = i + break + } + } + } + + private fun getDefaultSelection(): Int = + when (defaultSelection) { + DefaultSelection.Start -> currentList.indices.first + DefaultSelection.End -> currentList.indices.last + } + + enum class DefaultSelection { Start, End } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AddonAdapter.kt new file mode 100644 index 000000000..ff254d9b7 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AddonAdapter.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding +import org.yuzu.yuzu_emu.model.Patch +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class AddonAdapter(val addonViewModel: AddonViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { + ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AddonViewHolder(it) } + } + + inner class AddonViewHolder(val binding: ListItemAddonBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Patch) { + binding.root.setOnClickListener { + binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked + } + binding.title.text = model.name + binding.version.text = model.version + binding.addonCheckbox.setOnCheckedChangeListener { _, checked -> + model.enabled = checked + } + binding.addonCheckbox.isChecked = model.enabled + binding.buttonDelete.setOnClickListener { + addonViewModel.setAddonToDelete(model) + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AppletAdapter.kt new file mode 100644 index 000000000..41d7f72b8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/AppletAdapter.kt @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.FragmentActivity +import androidx.navigation.findNavController +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding +import org.yuzu.yuzu_emu.model.Applet +import org.yuzu.yuzu_emu.model.AppletInfo +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class AppletAdapter(val activity: FragmentActivity, applets: List) : + AbstractListAdapter(applets) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AppletAdapter.AppletViewHolder { + CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return AppletViewHolder(it) } + } + + inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Applet) { + binding.title.setText(model.titleId) + binding.description.setText(model.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + model.iconId, + binding.icon.context.theme + ) + ) + + binding.root.setOnClickListener { onClick(model) } + } + + fun onClick(applet: Applet) { + val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId) + if (appletPath.isEmpty()) { + Toast.makeText( + binding.root.context, + R.string.applets_error_applet, + Toast.LENGTH_SHORT + ).show() + return + } + + if (applet.appletInfo == AppletInfo.Cabinet) { + binding.root.findNavController() + .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment) + return + } + + NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId) + val appletGame = Game( + title = YuzuApplication.appContext.getString(applet.titleId), + path = appletPath + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) + binding.root.findNavController().navigate(action) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt new file mode 100644 index 000000000..a56137148 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.DialogListItemBinding +import org.yuzu.yuzu_emu.model.CabinetMode +import org.yuzu.yuzu_emu.adapters.CabinetLauncherDialogAdapter.CabinetModeViewHolder +import org.yuzu.yuzu_emu.model.AppletInfo +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class CabinetLauncherDialogAdapter(val fragment: Fragment) : + AbstractListAdapter( + CabinetMode.values().copyOfRange(1, CabinetMode.entries.size).toList() + ) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CabinetModeViewHolder { + DialogListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return CabinetModeViewHolder(it) } + } + + inner class CabinetModeViewHolder(val binding: DialogListItemBinding) : + AbstractViewHolder(binding) { + override fun bind(model: CabinetMode) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + model.iconId, + binding.icon.context.theme + ) + ) + binding.title.setText(model.titleId) + + binding.root.setOnClickListener { onClick(model) } + } + + private fun onClick(mode: CabinetMode) { + val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.Cabinet.entryId) + NativeLibrary.setCurrentAppletId(AppletInfo.Cabinet.appletId) + NativeLibrary.setCabinetMode(mode.id) + val appletGame = Game( + title = YuzuApplication.appContext.getString(R.string.cabinet_applet), + path = appletPath + ) + val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) + fragment.findNavController().navigate(action) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/DriverAdapter.kt new file mode 100644 index 000000000..50663ad91 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/DriverAdapter.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.CardDriverOptionBinding +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.model.Driver +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class DriverAdapter(private val driverViewModel: DriverViewModel) : + AbstractSingleSelectionList( + driverViewModel.driverList.value + ) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder { + CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return DriverViewHolder(it) } + } + + inner class DriverViewHolder(val binding: CardDriverOptionBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Driver) { + binding.apply { + radioButton.isChecked = model.selected + root.setOnClickListener { + selectItem(bindingAdapterPosition) { + driverViewModel.onDriverSelected(it) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + } + } + buttonDelete.setOnClickListener { + removeSelectableItem( + bindingAdapterPosition + ) { removedPosition: Int, selectedPosition: Int -> + driverViewModel.onDriverRemoved(removedPosition, selectedPosition) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + } + } + + // Delay marquee by 3s + title.marquee() + version.marquee() + description.marquee() + title.text = model.title + version.text = model.version + description.text = model.description + buttonDelete.setVisible( + model.title != binding.root.context.getString(R.string.system_gpu_driver) + ) + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/FolderAdapter.kt new file mode 100644 index 000000000..5cbd15d2a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/FolderAdapter.kt @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.fragment.app.FragmentActivity +import org.yuzu.yuzu_emu.databinding.CardFolderBinding +import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) : + AbstractDiffAdapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): FolderAdapter.FolderViewHolder { + CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return FolderViewHolder(it) } + } + + inner class FolderViewHolder(val binding: CardFolderBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameDir) { + binding.apply { + path.text = Uri.parse(model.uriString).path + path.marquee() + + buttonEdit.setOnClickListener { + GameFolderPropertiesDialogFragment.newInstance(model) + .show( + activity.supportFragmentManager, + GameFolderPropertiesDialogFragment.TAG + ) + } + + buttonDelete.setOnClickListener { + gamesViewModel.removeFolder(model) + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GameAdapter.kt new file mode 100644 index 000000000..b1f247ac3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GameAdapter.kt @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.net.Uri +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.navigation.findNavController +import androidx.preference.PreferenceManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.databinding.CardGameBinding +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.utils.GameIconUtils +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class GameAdapter(private val activity: AppCompatActivity) : + AbstractDiffAdapter(exact = false) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { + CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return GameViewHolder(it) } + } + + inner class GameViewHolder(val binding: CardGameBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Game) { + binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP + GameIconUtils.loadGameIcon(model, binding.imageGameScreen) + + binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") + + binding.textGameTitle.marquee() + binding.cardGame.setOnClickListener { onClick(model) } + binding.cardGame.setOnLongClickListener { onLongClick(model) } + } + + fun onClick(game: Game) { + val gameExists = DocumentFile.fromSingleUri( + YuzuApplication.appContext, + Uri.parse(game.path) + )?.exists() == true + if (!gameExists) { + Toast.makeText( + YuzuApplication.appContext, + R.string.loader_error_file_not_found, + Toast.LENGTH_LONG + ).show() + + ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) + return + } + + val preferences = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + preferences.edit() + .putLong( + game.keyLastPlayedTime, + System.currentTimeMillis() + ) + .apply() + + activity.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val shortcut = + ShortcutInfoCompat.Builder(YuzuApplication.appContext, game.path) + .setShortLabel(game.title) + .setIcon(GameIconUtils.getShortcutIcon(activity, game)) + .setIntent(game.launchIntent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(YuzuApplication.appContext, shortcut) + } + } + + val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true) + binding.root.findNavController().navigate(action) + } + + fun onLongClick(game: Game): Boolean { + val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game) + binding.root.findNavController().navigate(action) + return true + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GamePropertiesAdapter.kt new file mode 100644 index 000000000..7366e2c77 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/GamePropertiesAdapter.kt @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LifecycleOwner +import org.yuzu.yuzu_emu.databinding.CardInstallableIconBinding +import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding +import org.yuzu.yuzu_emu.model.GameProperty +import org.yuzu.yuzu_emu.model.InstallableProperty +import org.yuzu.yuzu_emu.model.SubmenuProperty +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.collect +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class GamePropertiesAdapter( + private val viewLifecycle: LifecycleOwner, + private var properties: List +) : AbstractListAdapter>(properties) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + PropertyType.Submenu.ordinal -> { + SubmenuPropertyViewHolder( + CardSimpleOutlinedBinding.inflate( + inflater, + parent, + false + ) + ) + } + + else -> InstallablePropertyViewHolder( + CardInstallableIconBinding.inflate( + inflater, + parent, + false + ) + ) + } + } + + override fun getItemViewType(position: Int): Int { + return when (properties[position]) { + is SubmenuProperty -> PropertyType.Submenu.ordinal + else -> PropertyType.Installable.ordinal + } + } + + inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameProperty) { + val submenuProperty = model as SubmenuProperty + + binding.root.setOnClickListener { + submenuProperty.action.invoke() + } + + binding.title.setText(submenuProperty.titleId) + binding.description.setText(submenuProperty.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + submenuProperty.iconId, + binding.icon.context.theme + ) + ) + + binding.details.marquee() + if (submenuProperty.details != null) { + binding.details.setVisible(true) + binding.details.text = submenuProperty.details.invoke() + } else if (submenuProperty.detailsFlow != null) { + binding.details.setVisible(true) + submenuProperty.detailsFlow.collect(viewLifecycle) { binding.details.text = it } + } else { + binding.details.setVisible(false) + } + } + } + + inner class InstallablePropertyViewHolder(val binding: CardInstallableIconBinding) : + AbstractViewHolder(binding) { + override fun bind(model: GameProperty) { + val installableProperty = model as InstallableProperty + + binding.title.setText(installableProperty.titleId) + binding.description.setText(installableProperty.descriptionId) + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.context.resources, + installableProperty.iconId, + binding.icon.context.theme + ) + ) + + binding.buttonInstall.setVisible(installableProperty.install != null) + binding.buttonInstall.setOnClickListener { installableProperty.install?.invoke() } + binding.buttonExport.setVisible(installableProperty.export != null) + binding.buttonExport.setOnClickListener { installableProperty.export?.invoke() } + } + } + + enum class PropertyType { + Submenu, + Installable + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/HomeSettingAdapter.kt new file mode 100644 index 000000000..0bd196673 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/HomeSettingAdapter.kt @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LifecycleOwner +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.model.HomeSetting +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.collect +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class HomeSettingAdapter( + private val activity: AppCompatActivity, + private val viewLifecycle: LifecycleOwner, + options: List +) : AbstractListAdapter(options) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { + CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return HomeOptionViewHolder(it) } + } + + inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : + AbstractViewHolder(binding) { + override fun bind(model: HomeSetting) { + binding.optionTitle.text = activity.resources.getString(model.titleId) + binding.optionDescription.text = activity.resources.getString(model.descriptionId) + binding.optionIcon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + model.iconId, + activity.theme + ) + ) + + when (model.titleId) { + R.string.get_early_access -> + binding.optionLayout.background = + ContextCompat.getDrawable( + binding.optionCard.context, + R.drawable.premium_background + ) + } + + if (!model.isEnabled.invoke()) { + binding.optionTitle.alpha = 0.5f + binding.optionDescription.alpha = 0.5f + binding.optionIcon.alpha = 0.5f + } + + model.details.collect(viewLifecycle) { updateOptionDetails(it) } + binding.optionDetail.marquee() + + binding.root.setOnClickListener { onClick(model) } + } + + private fun onClick(model: HomeSetting) { + if (model.isEnabled.invoke()) { + model.onClick.invoke() + } else { + MessageDialogFragment.newInstance( + activity, + titleId = model.disabledTitleId, + descriptionId = model.disabledMessageId + ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun updateOptionDetails(detailString: String) { + if (detailString.isNotEmpty()) { + binding.optionDetail.text = detailString + binding.optionDetail.setVisible(true) + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/InstallableAdapter.kt new file mode 100644 index 000000000..1ba75fa2f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/InstallableAdapter.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import org.yuzu.yuzu_emu.databinding.CardInstallableBinding +import org.yuzu.yuzu_emu.model.Installable +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class InstallableAdapter(installables: List) : + AbstractListAdapter(installables) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): InstallableAdapter.InstallableViewHolder { + CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return InstallableViewHolder(it) } + } + + inner class InstallableViewHolder(val binding: CardInstallableBinding) : + AbstractViewHolder(binding) { + override fun bind(model: Installable) { + binding.title.setText(model.titleId) + binding.description.setText(model.descriptionId) + + binding.buttonInstall.setVisible(model.install != null) + binding.buttonInstall.setOnClickListener { model.install?.invoke() } + binding.buttonExport.setVisible(model.export != null) + binding.buttonExport.setOnClickListener { model.export?.invoke() } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/LicenseAdapter.kt new file mode 100644 index 000000000..1379968f9 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/LicenseAdapter.kt @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment +import org.yuzu.yuzu_emu.model.License +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class LicenseAdapter(private val activity: AppCompatActivity, licenses: List) : + AbstractListAdapter(licenses) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { + ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return LicenseViewHolder(it) } + } + + inner class LicenseViewHolder(val binding: ListItemSettingBinding) : + AbstractViewHolder(binding) { + override fun bind(model: License) { + binding.apply { + textSettingName.text = root.context.getString(model.titleId) + textSettingDescription.text = root.context.getString(model.descriptionId) + textSettingValue.setVisible(false) + + root.setOnClickListener { onClick(model) } + } + } + + private fun onClick(license: License) { + LicenseBottomSheetDialogFragment.newInstance(license) + .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/SetupAdapter.kt new file mode 100644 index 000000000..a5f610b31 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/adapters/SetupAdapter.kt @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.adapters + +import android.text.Html +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.ViewModelProvider +import com.google.android.material.button.MaterialButton +import org.yuzu.yuzu_emu.databinding.PageSetupBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.SetupCallback +import org.yuzu.yuzu_emu.model.SetupPage +import org.yuzu.yuzu_emu.model.StepState +import org.yuzu.yuzu_emu.utils.ViewUtils +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder + +class SetupAdapter(val activity: AppCompatActivity, pages: List) : + AbstractListAdapter(pages) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { + PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return SetupPageViewHolder(it) } + } + + inner class SetupPageViewHolder(val binding: PageSetupBinding) : + AbstractViewHolder(binding), SetupCallback { + override fun bind(model: SetupPage) { + if (model.stepCompleted.invoke() == StepState.COMPLETE) { + binding.buttonAction.setVisible(visible = false, gone = false) + binding.textConfirmation.setVisible(true) + } + + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + activity.resources, + model.iconId, + activity.theme + ) + ) + binding.textTitle.text = activity.resources.getString(model.titleId) + binding.textDescription.text = + Html.fromHtml(activity.resources.getString(model.descriptionId), 0) + + binding.buttonAction.apply { + text = activity.resources.getString(model.buttonTextId) + if (model.buttonIconId != 0) { + icon = ResourcesCompat.getDrawable( + activity.resources, + model.buttonIconId, + activity.theme + ) + } + iconGravity = + if (model.leftAlignedIcon) { + MaterialButton.ICON_GRAVITY_START + } else { + MaterialButton.ICON_GRAVITY_END + } + setOnClickListener { + model.buttonAction.invoke(this@SetupPageViewHolder) + } + } + } + + override fun onStepCompleted() { + ViewUtils.hideView(binding.buttonAction, 200) + ViewUtils.showView(binding.textConfirmation, 200) + ViewModelProvider(activity)[HomeViewModel::class.java].setShouldPageForward(true) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt new file mode 100644 index 000000000..e058067c9 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard + +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import android.view.View +import android.view.WindowInsets +import android.view.inputmethod.InputMethodManager +import androidx.annotation.Keep +import androidx.core.view.ViewCompat +import java.io.Serializable +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment + +@Keep +object SoftwareKeyboard { + lateinit var data: KeyboardData + val dataLock = Object() + + private fun executeNormalImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + data = KeyboardData(SwkbdResult.Cancel.ordinal, "") + val fragment = KeyboardDialogFragment.newInstance(config) + fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) + } + + private fun executeInlineImpl(config: KeyboardConfig) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + + val overlayView = emulationActivity!!.findViewById(R.id.surface_input_overlay) + val im = + overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) + + // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. + val handler = Handler(Looper.myLooper()!!) + val delayMs = 500 + handler.postDelayed( + object : Runnable { + override fun run() { + val insets = ViewCompat.getRootWindowInsets(overlayView) + val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) + if (isKeyboardVisible) { + handler.postDelayed(this, delayMs.toLong()) + return + } + + // No longer visible, submit the result. + NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) + } + }, + delayMs.toLong() + ) + } + + @JvmStatic + fun executeNormal(config: KeyboardConfig): KeyboardData { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } + synchronized(dataLock) { + dataLock.wait() + } + return data + } + + @JvmStatic + fun executeInline(config: KeyboardConfig) { + NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } + } + + // Corresponds to Service::AM::Applets::SwkbdType + enum class SwkbdType { + Normal, + NumberPad, + Qwerty, + Unknown3, + Latin, + SimplifiedChinese, + TraditionalChinese, + Korean + } + + // Corresponds to Service::AM::Applets::SwkbdPasswordMode + enum class SwkbdPasswordMode { + Disabled, + Enabled + } + + // Corresponds to Service::AM::Applets::SwkbdResult + enum class SwkbdResult { + Ok, + Cancel + } + + @Keep + data class KeyboardConfig( + var ok_text: String? = null, + var header_text: String? = null, + var sub_text: String? = null, + var guide_text: String? = null, + var initial_text: String? = null, + var left_optional_symbol_key: Short = 0, + var right_optional_symbol_key: Short = 0, + var max_text_length: Int = 0, + var min_text_length: Int = 0, + var initial_cursor_position: Int = 0, + var type: Int = 0, + var password_mode: Int = 0, + var text_draw_type: Int = 0, + var key_disable_flags: Int = 0, + var use_blur_background: Boolean = false, + var enable_backspace_button: Boolean = false, + var enable_return_button: Boolean = false, + var disable_cancel_button: Boolean = false + ) : Serializable + + // Corresponds to Frontend::KeyboardData + @Keep + data class KeyboardData(var result: Int, var text: String) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt new file mode 100644 index 000000000..607a3d506 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.applets.keyboard.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputFilter +import android.text.InputType +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard +import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable + +class KeyboardDialogFragment : DialogFragment() { + private lateinit var binding: DialogEditTextBinding + private lateinit var config: KeyboardConfig + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + config = requireArguments().serializable(CONFIG)!! + + // Set up the input + binding.editText.hint = config.initial_text + binding.editText.isSingleLine = !config.enable_return_button + binding.editText.filters = + arrayOf(InputFilter.LengthFilter(config.max_text_length)) + + // Handle input type + var inputType: Int + when (config.type) { + SoftwareKeyboard.SwkbdType.Normal.ordinal, + SoftwareKeyboard.SwkbdType.Qwerty.ordinal, + SoftwareKeyboard.SwkbdType.Unknown3.ordinal, + SoftwareKeyboard.SwkbdType.Latin.ordinal, + SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, + SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, + SoftwareKeyboard.SwkbdType.Korean.ordinal -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { + inputType = InputType.TYPE_CLASS_NUMBER + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD + } + } + else -> { + inputType = InputType.TYPE_CLASS_TEXT + if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { + inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + } + binding.editText.inputType = inputType + + val headerText = + config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } + val okText = + config.ok_text!!.ifEmpty { resources.getString(R.string.submit) } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(headerText) + .setView(binding.root) + .setPositiveButton(okText) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal + SoftwareKeyboard.data.text = binding.editText.text.toString() + } + .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> + SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + synchronized(SoftwareKeyboard.dataLock) { + SoftwareKeyboard.dataLock.notifyAll() + } + } + + companion object { + const val TAG = "KeyboardDialogFragment" + const val CONFIG = "keyboard_config" + + fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { + val frag = KeyboardDialogFragment() + val args = Bundle() + args.putSerializable(CONFIG, config) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt new file mode 100644 index 000000000..6f4b5b13f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.disk_shader_cache + +import androidx.annotation.Keep +import androidx.lifecycle.ViewModelProvider +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.model.EmulationViewModel +import org.yuzu.yuzu_emu.utils.Log + +@Keep +object DiskShaderCacheProgress { + private lateinit var emulationViewModel: EmulationViewModel + + private fun prepareViewModel() { + emulationViewModel = + ViewModelProvider( + NativeLibrary.sEmulationActivity.get() as EmulationActivity + )[EmulationViewModel::class.java] + } + + @JvmStatic + fun loadProgress(stage: Int, progress: Int, max: Int) { + val emulationActivity = NativeLibrary.sEmulationActivity.get() + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present") + return + } + + emulationActivity.runOnUiThread { + when (LoadCallbackStage.values()[stage]) { + LoadCallbackStage.Prepare -> prepareViewModel() + LoadCallbackStage.Build -> emulationViewModel.updateProgress( + emulationActivity.getString(R.string.building_shaders), + progress, + max + ) + + LoadCallbackStage.Complete -> {} + } + } + } + + // Equivalent to VideoCore::LoadCallbackStage + enum class LoadCallbackStage { + Prepare, Build, Complete + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/DocumentProvider.kt new file mode 100644 index 000000000..f3be156b5 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/DocumentProvider.kt @@ -0,0 +1,341 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +// SPDX-License-Identifier: MPL-2.0 +// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) + +package org.yuzu.yuzu_emu.features + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import java.io.* +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.getPublicFilesDir + +class DocumentProvider : DocumentsProvider() { + private val baseDirectory: File + get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) + + companion object { + private val DEFAULT_ROOT_PROJECTION: Array = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_MIME_TYPES, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + DocumentsContract.Root.COLUMN_AVAILABLE_BYTES + ) + + private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE + ) + + const val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".user" + const val ROOT_ID: String = "root" + } + + override fun onCreate(): Boolean { + return true + } + + /** + * @return The [File] that corresponds to the document ID supplied by [getDocumentId] + */ + private fun getFile(documentId: String): File { + if (documentId.startsWith(ROOT_ID)) { + val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) + if (!file.exists()) { + throw FileNotFoundException( + "${file.absolutePath} ($documentId) not found" + ) + } + return file + } else { + throw FileNotFoundException("'$documentId' is not in any known root") + } + } + + /** + * @return A unique ID for the provided [File] + */ + private fun getDocumentId(file: File): String { + return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" + } + + override fun queryRoots(projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + + cursor.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add(DocumentsContract.Root.COLUMN_SUMMARY, null) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD + ) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) + add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") + add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + + return cursor + } + + override fun queryDocument(documentId: String?, projection: Array?): Cursor { + val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + return includeFile(cursor, documentId, null) + } + + override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { + return documentId?.startsWith(parentDocumentId!!) ?: false + } + + /** + * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file + */ + private fun File.resolveWithoutConflict(name: String): File { + var file = resolve(name) + if (file.exists()) { + var noConflictId = + 1 // Makes sure two files don't have the same name by adding a number to the end + val extension = name.substringAfterLast('.') + val baseName = name.substringBeforeLast('.') + while (file.exists()) + file = resolve("$baseName (${noConflictId++}).$extension") + } + return file + } + + override fun createDocument( + parentDocumentId: String?, + mimeType: String?, + displayName: String + ): String { + val parentFile = getFile(parentDocumentId!!) + val newFile = parentFile.resolveWithoutConflict(displayName) + + try { + if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { + if (!newFile.mkdir()) { + throw IOException("Failed to create directory") + } + } else { + if (!newFile.createNewFile()) { + throw IOException("Failed to create file") + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun deleteDocument(documentId: String?) { + val file = getFile(documentId!!) + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun removeDocument(documentId: String, parentDocumentId: String?) { + val parent = getFile(parentDocumentId!!) + val file = getFile(documentId) + + if (parent == file || file.parentFile == null || file.parentFile!! == parent) { + if (!file.delete()) { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } else { + throw FileNotFoundException("Couldn't delete document with ID '$documentId'") + } + } + + override fun renameDocument(documentId: String?, displayName: String?): String { + if (displayName == null) { + throw FileNotFoundException( + "Couldn't rename document '$documentId' as the new name is null" + ) + } + + val sourceFile = getFile(documentId!!) + val sourceParentFile = sourceFile.parentFile + ?: throw FileNotFoundException( + "Couldn't rename document '$documentId' as it has no parent" + ) + val destFile = sourceParentFile.resolve(displayName) + + try { + if (!sourceFile.renameTo(destFile)) { + throw FileNotFoundException( + "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'" + ) + } + } catch (e: Exception) { + throw FileNotFoundException( + "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': " + + "${e.message}" + ) + } + + return getDocumentId(destFile) + } + + private fun copyDocument( + sourceDocumentId: String, + sourceParentDocumentId: String, + targetParentDocumentId: String? + ): String { + if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) { + throw FileNotFoundException( + "Couldn't copy document '$sourceDocumentId' as its parent is not " + + "'$sourceParentDocumentId'" + ) + } + + return copyDocument(sourceDocumentId, targetParentDocumentId) + } + + override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { + val parent = getFile(targetParentDocumentId!!) + val oldFile = getFile(sourceDocumentId) + val newFile = parent.resolveWithoutConflict(oldFile.name) + + try { + if (!( + newFile.createNewFile() && newFile.setWritable(true) && + newFile.setReadable(true) + ) + ) { + throw IOException("Couldn't create new file") + } + + FileInputStream(oldFile).use { inStream -> + FileOutputStream(newFile).use { outStream -> + inStream.copyTo(outStream) + } + } + } catch (e: IOException) { + throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") + } + + return getDocumentId(newFile) + } + + override fun moveDocument( + sourceDocumentId: String, + sourceParentDocumentId: String?, + targetParentDocumentId: String? + ): String { + try { + val newDocumentId = copyDocument( + sourceDocumentId, + sourceParentDocumentId!!, + targetParentDocumentId + ) + removeDocument(sourceDocumentId, sourceParentDocumentId) + return newDocumentId + } catch (e: FileNotFoundException) { + throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") + } + } + + private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { + val localDocumentId = documentId ?: file?.let { getDocumentId(it) } + val localFile = file ?: getFile(documentId!!) + + var flags = 0 + if (localFile.isDirectory && localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else if (localFile.canWrite()) { + flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + cursor.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) + add( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + if (localFile == baseDirectory) { + context!!.getString(R.string.app_name) + } else { + localFile.name + } + ) + add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + if (localFile == baseDirectory) { + add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) + } + } + + return cursor + } + + private fun getTypeForFile(file: File): Any { + return if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + getTypeForName(file.name) + } + } + + private fun getTypeForName(name: String): Any { + val lastDot = name.lastIndexOf('.') + if (lastDot >= 0) { + val extension = name.substring(lastDot + 1) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mime != null) { + return mime + } + } + return "application/octect-stream" + } + + override fun queryChildDocuments( + parentDocumentId: String?, + projection: Array?, + sortOrder: String? + ): Cursor { + var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + + val parent = getFile(parentDocumentId!!) + for (file in parent.listFiles()!!) + cursor = includeFile(cursor, null, file) + + return cursor + } + + override fun openDocument( + documentId: String?, + mode: String?, + signal: CancellationSignal? + ): ParcelFileDescriptor { + val file = documentId?.let { getFile(it) } + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/NativeInput.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/NativeInput.kt new file mode 100644 index 000000000..15d776311 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/NativeInput.kt @@ -0,0 +1,416 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.ButtonName +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ParamPackage +import android.view.InputDevice + +object NativeInput { + /** + * Default controller id for each device + */ + const val Player1Device = 0 + const val Player2Device = 1 + const val Player3Device = 2 + const val Player4Device = 3 + const val Player5Device = 4 + const val Player6Device = 5 + const val Player7Device = 6 + const val Player8Device = 7 + const val ConsoleDevice = 8 + + /** + * Button states + */ + object ButtonState { + const val RELEASED = 0 + const val PRESSED = 1 + } + + /** + * Returns true if pro controller isn't available and handheld is. + * Intended to check where the input overlay should direct its inputs. + */ + external fun isHandheldOnly(): Boolean + + /** + * Handles button press events for a gamepad. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param buttonId The Android Keycode corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + external fun onGamePadButtonEvent( + guid: String, + port: Int, + buttonId: Int, + action: Int + ) + + /** + * Handles axis movement events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param axis The axis ID. + * @param value Value along the given axis. + */ + external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float) + + /** + * Handles motion events. + * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. + * @param port Port determined by controller connection order. + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onGamePadMotionEvent( + guid: String, + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Signals and load a nfc tag + * @param data Byte array containing all the data from a nfc tag. + */ + external fun onReadNfcTag(data: ByteArray?) + + /** + * Removes current loaded nfc tag. + */ + external fun onRemoveNfcTag() + + /** + * Handles touch press events. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch movement. + * @param fingerId The finger id corresponding to this event. + * @param xAxis The value of the x-axis on the touchscreen. + * @param yAxis The value of the y-axis on the touchscreen. + */ + external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float) + + /** + * Handles touch release events. + * @param fingerId The finger id corresponding to this event + */ + external fun onTouchReleased(fingerId: Int) + + /** + * Sends a button input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param button The [NativeButton] corresponding to this event. + * @param action Mask identifying which action is happening (button pressed down, or button released). + */ + fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) = + onOverlayButtonEventImpl(port, button.int, action) + + private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int) + + /** + * Sends a joystick input to the global virtual controllers. + * @param port Port determined by controller connection order. + * @param stick The [NativeAnalog] corresponding to this event. + * @param xAxis Value along the X axis. + * @param yAxis Value along the Y axis. + */ + fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) = + onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis) + + private external fun onOverlayJoystickEventImpl( + port: Int, + stickId: Int, + xAxis: Float, + yAxis: Float + ) + + /** + * Handles motion events for the global virtual controllers. + * @param port Port determined by controller connection order + * @param deltaTimestamp The finger id corresponding to this event. + * @param xGyro The value of the x-axis for the gyroscope. + * @param yGyro The value of the y-axis for the gyroscope. + * @param zGyro The value of the z-axis for the gyroscope. + * @param xAccel The value of the x-axis for the accelerometer. + * @param yAccel The value of the y-axis for the accelerometer. + * @param zAccel The value of the z-axis for the accelerometer. + */ + external fun onDeviceMotionEvent( + port: Int, + deltaTimestamp: Long, + xGyro: Float, + yGyro: Float, + zGyro: Float, + xAccel: Float, + yAccel: Float, + zAccel: Float + ) + + /** + * Reloads all input devices from the currently loaded Settings::values.players into HID Core + */ + external fun reloadInputDevices() + + /** + * Registers a controller to be used with mapping + * @param device An [InputDevice] or the input overlay wrapped with [YuzuInputDevice] + */ + external fun registerController(device: YuzuInputDevice) + + /** + * Gets the names of input devices that have been registered with the input subsystem via [registerController] + */ + external fun getInputDevices(): Array + + /** + * Reads all input profiles from disk. Must be called before creating a profile picker. + */ + external fun loadInputProfiles() + + /** + * Gets the names of each available input profile. + */ + external fun getInputProfileNames(): Array + + /** + * Checks if the user-provided name for an input profile is valid. + * @param name User-provided name for an input profile. + * @return Whether [name] is valid or not. + */ + external fun isProfileNameValid(name: String): Boolean + + /** + * Creates a new input profile. + * @param name The new profile's name. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether creating the profile was successful or not. + */ + external fun createProfile(name: String, playerIndex: Int): Boolean + + /** + * Deletes an input profile. + * @param name Name of the profile to delete. + * @param playerIndex Index of the player that's currently being edited. Used to remove the profile + * name from this player's config if they have it loaded. + * @return Whether deleting this profile was successful or not. + */ + external fun deleteProfile(name: String, playerIndex: Int): Boolean + + /** + * Loads an input profile. + * @param name Name of the input profile to load. + * @param playerIndex Index of the player that will have this profile loaded. + * @return Whether loading this profile was successful or not. + */ + external fun loadProfile(name: String, playerIndex: Int): Boolean + + /** + * Saves an input profile. + * @param name Name of the profile to save. + * @param playerIndex Index of the player that's currently being edited. Used to write the profile + * name to this player's config. + * @return Whether saving the profile was successful or not. + */ + external fun saveProfile(name: String, playerIndex: Int): Boolean + + /** + * Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues] + * Must be used while per-game config is loaded. + */ + external fun loadPerGameConfiguration( + playerIndex: Int, + selectedIndex: Int, + selectedProfileName: String + ) + + /** + * Tells the input subsystem to start listening for inputs to map. + * @param type Type of input to map as shown by the int property in each [InputType]. + */ + external fun beginMapping(type: Int) + + /** + * Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping. + * Must be run after [beginMapping] and before [stopMapping]. + */ + external fun getNextInput(): String + + /** + * Tells the input subsystem to stop listening for inputs to map. + */ + external fun stopMapping() + + /** + * Updates a controller's mappings with auto-mapping params. + * @param playerIndex Index of the player to auto-map. + * @param deviceParams [ParamPackage] representing the device to auto-map as received + * from [getInputDevices]. + * @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams]. + * Intended to be a way to provide a default name for a controller if the "display" param is empty. + */ + fun updateMappingsWithDefault( + playerIndex: Int, + deviceParams: ParamPackage, + displayName: String + ) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName) + + private external fun updateMappingsWithDefaultImpl( + playerIndex: Int, + deviceParams: String, + displayName: String + ) + + /** + * Gets the params for a specific button. + * @param playerIndex Index of the player to get params from. + * @param button The [NativeButton] to get params for. + * @return A [ParamPackage] representing a player's specific button. + */ + fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage = + ParamPackage(getButtonParamImpl(playerIndex, button.int)) + + private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String + + /** + * Sets the params for a specific button. + * @param playerIndex Index of the player to set params for. + * @param button The [NativeButton] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) = + setButtonParamImpl(playerIndex, button.int, param.serialize()) + + private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String) + + /** + * Gets the params for a specific stick. + * @param playerIndex Index of the player to get params from. + * @param stick The [NativeAnalog] to get params for. + * @return A [ParamPackage] representing a player's specific stick. + */ + fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage = + ParamPackage(getStickParamImpl(playerIndex, stick.int)) + + private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String + + /** + * Sets the params for a specific stick. + * @param playerIndex Index of the player to set params for. + * @param stick The [NativeAnalog] to set params for. + * @param param A [ParamPackage] to set. + */ + fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) = + setStickParamImpl(playerIndex, stick.int, param.serialize()) + + private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String) + + /** + * Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for + * a button/analog/other. + * @param param A [ParamPackage] that represents a specific button's params. + * @return The [ButtonName] for [param]. + */ + fun getButtonName(param: ParamPackage): ButtonName = + ButtonName.from(getButtonNameImpl(param.serialize())) + + private external fun getButtonNameImpl(param: String): Int + + /** + * Gets each supported [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get supported indexes for. + * @return List of each supported [NpadStyleIndex]. + */ + fun getSupportedStyleTags(playerIndex: Int): List = + getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) } + + private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray + + /** + * Gets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to get an [NpadStyleIndex] from. + * @return The [NpadStyleIndex] for a given player. + */ + fun getStyleIndex(playerIndex: Int): NpadStyleIndex = + NpadStyleIndex.from(getStyleIndexImpl(playerIndex)) + + private external fun getStyleIndexImpl(playerIndex: Int): Int + + /** + * Sets the [NpadStyleIndex] for a given player. + * @param playerIndex Index of the player to change. + * @param style The new style to set. + */ + fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) = + setStyleIndexImpl(playerIndex, style.int) + + private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int) + + /** + * Checks if a device is a controller. + * @param params [ParamPackage] for an input device retrieved from [getInputDevices] + * @return Whether the device is a controller or not. + */ + fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize()) + + private external fun isControllerImpl(params: String): Boolean + + /** + * Checks if a controller is connected + * @param playerIndex Index of the player to check. + * @return Whether the player is connected or not. + */ + external fun getIsConnected(playerIndex: Int): Boolean + + /** + * Connects/disconnects a controller and ensures that connection order stays in-tact. + * @param playerIndex Index of the player to connect/disconnect. + * @param connected Whether to connect or disconnect this controller. + */ + fun connectControllers(playerIndex: Int, connected: Boolean = true) { + val connectedControllers = mutableListOf().apply { + if (connected) { + for (i in 0 until 8) { + add(i <= playerIndex) + } + } else { + for (i in 0 until 8) { + add(i < playerIndex) + } + } + } + connectControllersImpl(connectedControllers.toBooleanArray()) + } + + private external fun connectControllersImpl(connected: BooleanArray) + + /** + * Resets all of the button and analog mappings for a player. + * @param playerIndex Index of the player that will have its mappings reset. + */ + external fun resetControllerMappings(playerIndex: Int) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuInputDevice.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuInputDevice.kt new file mode 100644 index 000000000..15cc38c7f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuInputDevice.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import android.view.InputDevice +import androidx.annotation.Keep +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.utils.InputHandler.getGUID + +@Keep +interface YuzuInputDevice { + fun getName(): String + + fun getGUID(): String + + fun getPort(): Int + + fun getSupportsVibration(): Boolean + + fun vibrate(intensity: Float) + + fun getAxes(): Array = arrayOf() + fun hasKeys(keys: IntArray): BooleanArray = BooleanArray(0) +} + +class YuzuPhysicalDevice( + private val device: InputDevice, + private val port: Int, + useSystemVibrator: Boolean +) : YuzuInputDevice { + private val vibrator = if (useSystemVibrator) { + YuzuVibrator.getSystemVibrator() + } else { + YuzuVibrator.getControllerVibrator(device) + } + + override fun getName(): String { + return device.name + } + + override fun getGUID(): String { + return device.getGUID() + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + return vibrator.supportsVibration() + } + + override fun vibrate(intensity: Float) { + vibrator.vibrate(intensity) + } + + override fun getAxes(): Array = device.motionRanges.map { it.axis }.toTypedArray() + override fun hasKeys(keys: IntArray): BooleanArray = device.hasKeys(*keys) +} + +class YuzuInputOverlayDevice( + private val vibration: Boolean, + private val port: Int +) : YuzuInputDevice { + private val vibrator = YuzuVibrator.getSystemVibrator() + + override fun getName(): String { + return YuzuApplication.appContext.getString(R.string.input_overlay) + } + + override fun getGUID(): String { + return "00000000000000000000000000000000" + } + + override fun getPort(): Int { + return port + } + + override fun getSupportsVibration(): Boolean { + if (vibration) { + return vibrator.supportsVibration() + } + return false + } + + override fun vibrate(intensity: Float) { + if (vibration) { + vibrator.vibrate(intensity) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuVibrator.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuVibrator.kt new file mode 100644 index 000000000..aac49ecae --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/YuzuVibrator.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input + +import android.content.Context +import android.os.Build +import android.os.CombinedVibration +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.view.InputDevice +import androidx.annotation.Keep +import androidx.annotation.RequiresApi +import org.yuzu.yuzu_emu.YuzuApplication + +@Keep +@Suppress("DEPRECATION") +interface YuzuVibrator { + fun supportsVibration(): Boolean + + fun vibrate(intensity: Float) + + companion object { + fun getControllerVibrator(device: InputDevice): YuzuVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + YuzuVibratorManager(device.vibratorManager) + } else { + YuzuVibratorManagerCompat(device.vibrator) + } + + fun getSystemVibrator(): YuzuVibrator = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = YuzuApplication.appContext + .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager + YuzuVibratorManager(vibratorManager) + } else { + val vibrator = YuzuApplication.appContext + .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator + YuzuVibratorManagerCompat(vibrator) + } + + fun getVibrationEffect(intensity: Float): VibrationEffect? { + if (intensity > 0f) { + return VibrationEffect.createOneShot( + 50, + (255.0 * intensity).toInt().coerceIn(1, 255) + ) + } + return null + } + } +} + +@RequiresApi(Build.VERSION_CODES.S) +class YuzuVibratorManager(private val vibratorManager: VibratorManager) : YuzuVibrator { + override fun supportsVibration(): Boolean { + return vibratorManager.vibratorIds.isNotEmpty() + } + + override fun vibrate(intensity: Float) { + val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return + vibratorManager.vibrate(CombinedVibration.createParallel(vibration)) + } +} + +class YuzuVibratorManagerCompat(private val vibrator: Vibrator) : YuzuVibrator { + override fun supportsVibration(): Boolean { + return vibrator.hasVibrator() + } + + override fun vibrate(intensity: Float) { + val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return + vibrator.vibrate(vibration) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/AnalogDirection.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/AnalogDirection.kt new file mode 100644 index 000000000..0a5fab2ae --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/AnalogDirection.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +enum class AnalogDirection(val int: Int, val param: String) { + Up(0, "up"), + Down(1, "down"), + Left(2, "left"), + Right(3, "right") +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/ButtonName.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/ButtonName.kt new file mode 100644 index 000000000..b8846ecad --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/ButtonName.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Loosely matches the enum in common/input.h +enum class ButtonName(val int: Int) { + Invalid(1), + + // This will display the engine name instead of the button name + Engine(2), + + // This will display the button by value instead of the button name + Value(3); + + companion object { + fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/InputType.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/InputType.kt new file mode 100644 index 000000000..f725231cb --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/InputType.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match the corresponding enum in input_common/main.h +enum class InputType(val int: Int) { + None(0), + Button(1), + Stick(2), + Motion(3), + Touch(4) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeAnalog.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeAnalog.kt new file mode 100644 index 000000000..c3b7a785d --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeAnalog.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeAnalog(val int: Int) { + LStick(0), + RStick(1); + + companion object { + fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeButton.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeButton.kt new file mode 100644 index 000000000..c5ccd7115 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeButton.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeButton(val int: Int) { + A(0), + B(1), + X(2), + Y(3), + LStick(4), + RStick(5), + L(6), + R(7), + ZL(8), + ZR(9), + Plus(10), + Minus(11), + + DLeft(12), + DUp(13), + DRight(14), + DDown(15), + + SLLeft(16), + SRLeft(17), + + Home(18), + Capture(19), + + SLRight(20), + SRRight(21); + + companion object { + fun from(int: Int): NativeButton = entries.firstOrNull { it.int == int } ?: A + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeTrigger.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeTrigger.kt new file mode 100644 index 000000000..625f352b4 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NativeTrigger.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +// Must match enum in src/common/settings_input.h +enum class NativeTrigger(val int: Int) { + LTrigger(0), + RTrigger(1) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NpadStyleIndex.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NpadStyleIndex.kt new file mode 100644 index 000000000..e2a3d7aff --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/NpadStyleIndex.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.R + +// Must match enum in src/core/hid/hid_types.h +enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) { + None(0), + Fullkey(3, R.string.pro_controller), + Handheld(4, R.string.handheld), + HandheldNES(4), + JoyconDual(5, R.string.dual_joycons), + JoyconLeft(6, R.string.left_joycon), + JoyconRight(7, R.string.right_joycon), + GameCube(8, R.string.gamecube_controller), + Pokeball(9), + NES(10), + SNES(12), + N64(13), + SegaGenesis(14), + SystemExt(32), + System(33); + + companion object { + fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/PlayerInput.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/PlayerInput.kt new file mode 100644 index 000000000..a84ac77a2 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/input/model/PlayerInput.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.input.model + +import androidx.annotation.Keep + +@Keep +data class PlayerInput( + var connected: Boolean, + var buttons: Array, + var analogs: Array, + var motions: Array, + + var vibrationEnabled: Boolean, + var vibrationStrength: Int, + + var bodyColorLeft: Long, + var bodyColorRight: Long, + var buttonColorLeft: Long, + var buttonColorRight: Long, + var profileName: String, + + var useSystemVibrator: Boolean +) { + // It's recommended to use the generated equals() and hashCode() methods + // when using arrays in a data class + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PlayerInput + + if (connected != other.connected) return false + if (!buttons.contentEquals(other.buttons)) return false + if (!analogs.contentEquals(other.analogs)) return false + if (!motions.contentEquals(other.motions)) return false + if (vibrationEnabled != other.vibrationEnabled) return false + if (vibrationStrength != other.vibrationStrength) return false + if (bodyColorLeft != other.bodyColorLeft) return false + if (bodyColorRight != other.bodyColorRight) return false + if (buttonColorLeft != other.buttonColorLeft) return false + if (buttonColorRight != other.buttonColorRight) return false + if (profileName != other.profileName) return false + return useSystemVibrator == other.useSystemVibrator + } + + override fun hashCode(): Int { + var result = connected.hashCode() + result = 31 * result + buttons.contentHashCode() + result = 31 * result + analogs.contentHashCode() + result = 31 * result + motions.contentHashCode() + result = 31 * result + vibrationEnabled.hashCode() + result = 31 * result + vibrationStrength + result = 31 * result + bodyColorLeft.hashCode() + result = 31 * result + bodyColorRight.hashCode() + result = 31 * result + buttonColorLeft.hashCode() + result = 31 * result + buttonColorRight.hashCode() + result = 31 * result + profileName.hashCode() + result = 31 * result + useSystemVibrator.hashCode() + return result + } + + fun hasMapping(): Boolean { + var hasMapping = false + buttons.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + analogs.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + motions.forEach { + if (it != "[empty]" && it.isNotEmpty()) { + hasMapping = true + } + } + return hasMapping + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt new file mode 100644 index 000000000..0ba465356 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractBooleanSetting : AbstractSetting { + fun getBoolean(needsGlobal: Boolean = false): Boolean + fun setBoolean(value: Boolean) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractByteSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractByteSetting.kt new file mode 100644 index 000000000..cf6300535 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractByteSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractByteSetting : AbstractSetting { + fun getByte(needsGlobal: Boolean = false): Byte + fun setByte(value: Byte) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractFloatSetting.kt new file mode 100644 index 000000000..c6c0bcf34 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractFloatSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractFloatSetting : AbstractSetting { + fun getFloat(needsGlobal: Boolean = false): Float + fun setFloat(value: Float) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractIntSetting.kt new file mode 100644 index 000000000..826402c34 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractIntSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractIntSetting : AbstractSetting { + fun getInt(needsGlobal: Boolean = false): Int + fun setInt(value: Int) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractLongSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractLongSetting.kt new file mode 100644 index 000000000..2b62cc06b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractLongSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractLongSetting : AbstractSetting { + fun getLong(needsGlobal: Boolean = false): Long + fun setLong(value: Long) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractSetting.kt new file mode 100644 index 000000000..3b78c7cf0 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +interface AbstractSetting { + val key: String + val defaultValue: Any + + val isRuntimeModifiable: Boolean + get() = NativeConfig.getIsRuntimeModifiable(key) + + val pairedSettingKey: String + get() = NativeConfig.getPairedSettingKey(key) + + val isSwitchable: Boolean + get() = NativeConfig.getIsSwitchable(key) + + var global: Boolean + get() = NativeConfig.usingGlobal(key) + set(value) = NativeConfig.setGlobal(key, value) + + val isSaveable: Boolean + get() = NativeConfig.getIsSaveable(key) + + fun getValueAsString(needsGlobal: Boolean = false): String + + fun reset() +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractShortSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractShortSetting.kt new file mode 100644 index 000000000..8bfa81e4a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractShortSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractShortSetting : AbstractSetting { + fun getShort(needsGlobal: Boolean = false): Short + fun setShort(value: Short) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractStringSetting.kt new file mode 100644 index 000000000..6ff8fd3f9 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/AbstractStringSetting.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +interface AbstractStringSetting : AbstractSetting { + fun getString(needsGlobal: Boolean = false): String + fun setString(value: String) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/BooleanSetting.kt new file mode 100644 index 000000000..664478472 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/BooleanSetting.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { + AUDIO_MUTED("audio_muted"), + CPU_DEBUG_MODE("cpu_debug_mode"), + FASTMEM("cpuopt_fastmem"), + FASTMEM_EXCLUSIVES("cpuopt_fastmem_exclusives"), + RENDERER_USE_SPEED_LIMIT("use_speed_limit"), + USE_DOCKED_MODE("use_docked_mode"), + RENDERER_USE_DISK_SHADER_CACHE("use_disk_shader_cache"), + RENDERER_FORCE_MAX_CLOCK("force_max_clock"), + RENDERER_ASYNCHRONOUS_SHADERS("use_asynchronous_shaders"), + RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), + RENDERER_DEBUG("debug"), + PICTURE_IN_PICTURE("picture_in_picture"), + USE_CUSTOM_RTC("custom_rtc_enabled"), + BLACK_BACKGROUNDS("black_backgrounds"), + JOYSTICK_REL_CENTER("joystick_rel_center"), + DPAD_SLIDE("dpad_slide"), + HAPTIC_FEEDBACK("haptic_feedback"), + SHOW_PERFORMANCE_OVERLAY("show_performance_overlay"), + SHOW_INPUT_OVERLAY("show_input_overlay"), + TOUCHSCREEN("touchscreen"), + SHOW_THERMAL_OVERLAY("show_thermal_overlay"); + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getBoolean(key, needsGlobal) + + override fun setBoolean(value: Boolean) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setBoolean(key, value) + } + + override val defaultValue: Boolean by lazy { NativeConfig.getDefaultToString(key).toBoolean() } + + override fun getValueAsString(needsGlobal: Boolean): String = getBoolean(needsGlobal).toString() + + override fun reset() = NativeConfig.setBoolean(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ByteSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ByteSetting.kt new file mode 100644 index 000000000..7b7fac211 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ByteSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class ByteSetting(override val key: String) : AbstractByteSetting { + AUDIO_VOLUME("volume"); + + override fun getByte(needsGlobal: Boolean): Byte = NativeConfig.getByte(key, needsGlobal) + + override fun setByte(value: Byte) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setByte(key, value) + } + + override val defaultValue: Byte by lazy { NativeConfig.getDefaultToString(key).toByte() } + + override fun getValueAsString(needsGlobal: Boolean): String = getByte(needsGlobal).toString() + + override fun reset() = NativeConfig.setByte(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/FloatSetting.kt new file mode 100644 index 000000000..4644824d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/FloatSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class FloatSetting(override val key: String) : AbstractFloatSetting { + // No float settings currently exist + EMPTY_SETTING(""); + + override fun getFloat(needsGlobal: Boolean): Float = NativeConfig.getFloat(key, false) + + override fun setFloat(value: Float) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setFloat(key, value) + } + + override val defaultValue: Float by lazy { NativeConfig.getDefaultToString(key).toFloat() } + + override fun getValueAsString(needsGlobal: Boolean): String = getFloat(needsGlobal).toString() + + override fun reset() = NativeConfig.setFloat(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/IntSetting.kt new file mode 100644 index 000000000..0165cb2d1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/IntSetting.kt @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class IntSetting(override val key: String) : AbstractIntSetting { + CPU_BACKEND("cpu_backend"), + CPU_ACCURACY("cpu_accuracy"), + REGION_INDEX("region_index"), + LANGUAGE_INDEX("language_index"), + RENDERER_BACKEND("backend"), + RENDERER_ACCURACY("gpu_accuracy"), + RENDERER_RESOLUTION("resolution_setup"), + RENDERER_VSYNC("use_vsync"), + RENDERER_SCALING_FILTER("scaling_filter"), + RENDERER_ANTI_ALIASING("anti_aliasing"), + RENDERER_SCREEN_LAYOUT("screen_layout"), + RENDERER_ASPECT_RATIO("aspect_ratio"), + AUDIO_OUTPUT_ENGINE("output_engine"), + MAX_ANISOTROPY("max_anisotropy"), + THEME("theme"), + THEME_MODE("theme_mode"), + OVERLAY_SCALE("control_scale"), + OVERLAY_OPACITY("control_opacity"), + LOCK_DRAWER("lock_drawer"), + VERTICAL_ALIGNMENT("vertical_alignment"), + FSR_SHARPENING_SLIDER("fsr_sharpening_slider"); + + override fun getInt(needsGlobal: Boolean): Int = NativeConfig.getInt(key, needsGlobal) + + override fun setInt(value: Int) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setInt(key, value) + } + + override val defaultValue: Int by lazy { NativeConfig.getDefaultToString(key).toInt() } + + override fun getValueAsString(needsGlobal: Boolean): String = getInt(needsGlobal).toString() + + override fun reset() = NativeConfig.setInt(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/LongSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/LongSetting.kt new file mode 100644 index 000000000..e3efd516c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/LongSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class LongSetting(override val key: String) : AbstractLongSetting { + CUSTOM_RTC("custom_rtc"); + + override fun getLong(needsGlobal: Boolean): Long = NativeConfig.getLong(key, needsGlobal) + + override fun setLong(value: Long) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setLong(key, value) + } + + override val defaultValue: Long by lazy { NativeConfig.getDefaultToString(key).toLong() } + + override fun getValueAsString(needsGlobal: Boolean): String = getLong(needsGlobal).toString() + + override fun reset() = NativeConfig.setLong(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/Settings.kt new file mode 100644 index 000000000..4f6b93bd2 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/Settings.kt @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication + +object Settings { + enum class MenuTag(val titleId: Int = 0) { + SECTION_ROOT(R.string.advanced_settings), + SECTION_SYSTEM(R.string.preferences_system), + SECTION_RENDERER(R.string.preferences_graphics), + SECTION_AUDIO(R.string.preferences_audio), + SECTION_INPUT(R.string.preferences_controls), + SECTION_INPUT_PLAYER_ONE, + SECTION_INPUT_PLAYER_TWO, + SECTION_INPUT_PLAYER_THREE, + SECTION_INPUT_PLAYER_FOUR, + SECTION_INPUT_PLAYER_FIVE, + SECTION_INPUT_PLAYER_SIX, + SECTION_INPUT_PLAYER_SEVEN, + SECTION_INPUT_PLAYER_EIGHT, + SECTION_THEME(R.string.preferences_theme), + SECTION_DEBUG(R.string.preferences_debug); + } + + fun getPlayerString(player: Int): String = + YuzuApplication.appContext.getString(R.string.preferences_player, player) + + const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" + const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" + + // Deprecated input overlay preference keys + const val PREF_CONTROL_SCALE = "controlScale" + const val PREF_CONTROL_OPACITY = "controlOpacity" + const val PREF_TOUCH_ENABLED = "isTouchEnabled" + const val PREF_BUTTON_A = "buttonToggle0" + const val PREF_BUTTON_B = "buttonToggle1" + const val PREF_BUTTON_X = "buttonToggle2" + const val PREF_BUTTON_Y = "buttonToggle3" + const val PREF_BUTTON_L = "buttonToggle4" + const val PREF_BUTTON_R = "buttonToggle5" + const val PREF_BUTTON_ZL = "buttonToggle6" + const val PREF_BUTTON_ZR = "buttonToggle7" + const val PREF_BUTTON_PLUS = "buttonToggle8" + const val PREF_BUTTON_MINUS = "buttonToggle9" + const val PREF_BUTTON_DPAD = "buttonToggle10" + const val PREF_STICK_L = "buttonToggle11" + const val PREF_STICK_R = "buttonToggle12" + const val PREF_BUTTON_STICK_L = "buttonToggle13" + const val PREF_BUTTON_STICK_R = "buttonToggle14" + const val PREF_BUTTON_HOME = "buttonToggle15" + const val PREF_BUTTON_SCREENSHOT = "buttonToggle16" + const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter" + const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable" + const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics" + const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" + const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" + val overlayPreferences = listOf( + PREF_BUTTON_A, + PREF_BUTTON_B, + PREF_BUTTON_X, + PREF_BUTTON_Y, + PREF_BUTTON_L, + PREF_BUTTON_R, + PREF_BUTTON_ZL, + PREF_BUTTON_ZR, + PREF_BUTTON_PLUS, + PREF_BUTTON_MINUS, + PREF_BUTTON_DPAD, + PREF_STICK_L, + PREF_STICK_R, + PREF_BUTTON_HOME, + PREF_BUTTON_SCREENSHOT, + PREF_BUTTON_STICK_L, + PREF_BUTTON_STICK_R + ) + + // Deprecated layout preference keys + const val PREF_LANDSCAPE_SUFFIX = "_Landscape" + const val PREF_PORTRAIT_SUFFIX = "_Portrait" + const val PREF_FOLDABLE_SUFFIX = "_Foldable" + val overlayLayoutSuffixes = listOf( + PREF_LANDSCAPE_SUFFIX, + PREF_PORTRAIT_SUFFIX, + PREF_FOLDABLE_SUFFIX + ) + + // Deprecated theme preference keys + const val PREF_THEME = "Theme" + const val PREF_THEME_MODE = "ThemeMode" + const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" + + enum class EmulationOrientation(val int: Int) { + Unspecified(0), + SensorLandscape(5), + Landscape(1), + ReverseLandscape(2), + SensorPortrait(6), + Portrait(4), + ReversePortrait(3); + + companion object { + fun from(int: Int): EmulationOrientation = + entries.firstOrNull { it.int == int } ?: Unspecified + } + } + + enum class EmulationVerticalAlignment(val int: Int) { + Top(1), + Center(0), + Bottom(2); + + companion object { + fun from(int: Int): EmulationVerticalAlignment = + entries.firstOrNull { it.int == int } ?: Center + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ShortSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ShortSetting.kt new file mode 100644 index 000000000..16eb4ffdd --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/ShortSetting.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class ShortSetting(override val key: String) : AbstractShortSetting { + RENDERER_SPEED_LIMIT("speed_limit"); + + override fun getShort(needsGlobal: Boolean): Short = NativeConfig.getShort(key, needsGlobal) + + override fun setShort(value: Short) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setShort(key, value) + } + + override val defaultValue: Short by lazy { NativeConfig.getDefaultToString(key).toShort() } + + override fun getValueAsString(needsGlobal: Boolean): String = getShort(needsGlobal).toString() + + override fun reset() = NativeConfig.setShort(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/StringSetting.kt new file mode 100644 index 000000000..6f16cf5b1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/StringSetting.kt @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model + +import org.yuzu.yuzu_emu.utils.NativeConfig + +enum class StringSetting(override val key: String) : AbstractStringSetting { + DRIVER_PATH("driver_path"), + DEVICE_NAME("device_name"); + + override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal) + + override fun setString(value: String) { + if (NativeConfig.isPerGameConfigLoaded()) { + global = false + } + NativeConfig.setString(key, value) + } + + override val defaultValue: String by lazy { NativeConfig.getDefaultToString(key) } + + override fun getValueAsString(needsGlobal: Boolean): String = getString(needsGlobal) + + override fun reset() = NativeConfig.setString(key, defaultValue) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt new file mode 100644 index 000000000..a2996725e --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.utils.ParamPackage + +class AnalogInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + val analogDirection: AnalogDirection, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Stick + + override fun getSelectedValue(): String { + val params = NativeInput.getStickParam(playerIndex, nativeAnalog) + val analog = analogToText(params, analogDirection.param) + return getDisplayString(params, analog) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setStickParam(playerIndex, nativeAnalog, param) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt new file mode 100644 index 000000000..786d09a7a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.utils.ParamPackage +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeButton + +class ButtonInputSetting( + override val playerIndex: Int, + val nativeButton: NativeButton, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val type = TYPE_INPUT + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val params = NativeInput.getButtonParam(playerIndex, nativeButton) + val button = buttonToText(params) + return getDisplayString(params, button) + } + + override fun setSelectedValue(param: ParamPackage) = + NativeInput.setButtonParam(playerIndex, nativeButton, param) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/DateTimeSetting.kt new file mode 100644 index 000000000..58febff1d --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/DateTimeSetting.kt @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractLongSetting + +class DateTimeSetting( + private val longSetting: AbstractLongSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(longSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_DATETIME_SETTING + + fun getValue(needsGlobal: Boolean = false): Long = longSetting.getLong(needsGlobal) + fun setValue(value: Long) = (setting as AbstractLongSetting).setLong(value) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/HeaderSetting.kt new file mode 100644 index 000000000..8a6a51d5c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/HeaderSetting.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes + +class HeaderSetting( + @StringRes titleId: Int = 0, + titleString: String = "" +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_HEADER +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputProfileSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputProfileSetting.kt new file mode 100644 index 000000000..c46de08c5 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputProfileSetting.kt @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.utils.NativeConfig + +class InputProfileSetting(private val playerIndex: Int) : + SettingsItem(emptySetting, R.string.profile, "", 0, "") { + override val type = TYPE_INPUT_PROFILE + + fun getCurrentProfile(): String = + NativeConfig.getInputSettings(true)[playerIndex].profileName + + fun getProfileNames(): Array = NativeInput.getInputProfileNames() + + fun isProfileNameValid(name: String): Boolean = NativeInput.isProfileNameValid(name) + + fun createProfile(name: String): Boolean = NativeInput.createProfile(name, playerIndex) + + fun deleteProfile(name: String): Boolean = NativeInput.deleteProfile(name, playerIndex) + + fun loadProfile(name: String): Boolean { + val result = NativeInput.loadProfile(name, playerIndex) + NativeInput.reloadInputDevices() + return result + } + + fun saveProfile(name: String): Boolean = NativeInput.saveProfile(name, playerIndex) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputSetting.kt new file mode 100644 index 000000000..2d118bff3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/InputSetting.kt @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.ButtonName +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.utils.ParamPackage + +sealed class InputSetting( + @StringRes titleId: Int, + titleString: String +) : SettingsItem(emptySetting, titleId, titleString, 0, "") { + override val type = TYPE_INPUT + abstract val inputType: InputType + abstract val playerIndex: Int + + protected val context get() = YuzuApplication.appContext + + abstract fun getSelectedValue(): String + + abstract fun setSelectedValue(param: ParamPackage) + + protected fun getDisplayString(params: ParamPackage, control: String): String { + val deviceName = params.get("display", "") + deviceName.ifEmpty { + return context.getString(R.string.not_set) + } + return "$deviceName: $control" + } + + private fun getDirectionName(direction: String): String = + when (direction) { + "up" -> context.getString(R.string.up) + "down" -> context.getString(R.string.down) + "left" -> context.getString(R.string.left) + "right" -> context.getString(R.string.right) + else -> direction + } + + protected fun buttonToText(param: ParamPackage): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + val toggle = if (param.get("toggle", false)) "~" else "" + val inverted = if (param.get("inverted", false)) "!" else "" + val invert = if (param.get("invert", "+") == "-") "-" else "" + val turbo = if (param.get("turbo", false)) "$" else "" + val commonButtonName = NativeInput.getButtonName(param) + + if (commonButtonName == ButtonName.Invalid) { + return context.getString(R.string.invalid) + } + + if (commonButtonName == ButtonName.Engine) { + return param.get("engine", "") + } + + if (commonButtonName == ButtonName.Value) { + if (param.has("hat")) { + val hat = getDirectionName(param.get("direction", "")) + return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat) + } + if (param.has("axis")) { + val axis = param.get("axis", "") + return context.getString( + R.string.qualified_button_stick_axis, + toggle, + inverted, + invert, + axis + ) + } + if (param.has("button")) { + val button = param.get("button", "") + return context.getString(R.string.qualified_button, turbo, toggle, inverted, button) + } + } + + return context.getString(R.string.unknown) + } + + protected fun analogToText(param: ParamPackage, direction: String): String { + if (!param.has("engine")) { + return context.getString(R.string.not_set) + } + + if (param.get("engine", "") == "analog_from_button") { + return buttonToText(ParamPackage(param.get(direction, ""))) + } + + if (!param.has("axis_x") || !param.has("axis_y")) { + return context.getString(R.string.unknown) + } + + val xAxis = param.get("axis_x", "") + val yAxis = param.get("axis_y", "") + val xInvert = param.get("invert_x", "+") == "-" + val yInvert = param.get("invert_y", "+") == "-" + + if (direction == "modifier") { + return context.getString(R.string.unused) + } + + when (direction) { + "up" -> { + val yInvertString = if (yInvert) "+" else "-" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "down" -> { + val yInvertString = if (yInvert) "-" else "+" + return context.getString(R.string.qualified_axis, yAxis, yInvertString) + } + + "left" -> { + val xInvertString = if (xInvert) "+" else "-" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + + "right" -> { + val xInvertString = if (xInvert) "-" else "+" + return context.getString(R.string.qualified_axis, xAxis, xInvertString) + } + } + + return context.getString(R.string.unknown) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt new file mode 100644 index 000000000..e024c793a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting + +class IntSingleChoiceSetting( + private val intSetting: AbstractIntSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val choices: Array, + val values: Array +) : SettingsItem(intSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_INT_SINGLE_CHOICE + + fun getValueAt(index: Int): Int = + if (values.indices.contains(index)) values[index] else -1 + + fun getChoiceAt(index: Int): String = + if (choices.indices.contains(index)) choices[index] else "" + + fun getSelectedValue(needsGlobal: Boolean = false) = intSetting.getInt(needsGlobal) + fun setSelectedValue(value: Int) = intSetting.setInt(value) + + val selectedValueIndex: Int + get() { + for (i in values.indices) { + if (values[i] == getSelectedValue()) { + return i + } + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt new file mode 100644 index 000000000..a1db3cc87 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.InputType +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.utils.ParamPackage + +class ModifierInputSetting( + override val playerIndex: Int, + val nativeAnalog: NativeAnalog, + @StringRes titleId: Int = 0, + titleString: String = "" +) : InputSetting(titleId, titleString) { + override val inputType = InputType.Button + + override fun getSelectedValue(): String { + val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + val modifierParam = ParamPackage(analogParam.get("modifier", "")) + return buttonToText(modifierParam) + } + + override fun setSelectedValue(param: ParamPackage) { + val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog) + newParam.set("modifier", param.serialize()) + NativeInput.setStickParam(playerIndex, nativeAnalog, newParam) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/RunnableSetting.kt new file mode 100644 index 000000000..06f607424 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/RunnableSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes + +class RunnableSetting( + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val isRunnable: Boolean, + @DrawableRes val iconId: Int = 0, + val runnable: () -> Unit +) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_RUNNABLE +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SettingsItem.kt new file mode 100644 index 000000000..5fdf98318 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SettingsItem.kt @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.ByteSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.LongSetting +import org.yuzu.yuzu_emu.features.settings.model.ShortSetting +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.utils.NativeConfig + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a [AbstractSetting] object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +abstract class SettingsItem( + val setting: AbstractSetting, + @StringRes val titleId: Int, + val titleString: String, + @StringRes val descriptionId: Int, + val descriptionString: String +) { + abstract val type: Int + + val title: String by lazy { + if (titleId != 0) { + return@lazy YuzuApplication.appContext.getString(titleId) + } + return@lazy titleString + } + + val description: String by lazy { + if (descriptionId != 0) { + return@lazy YuzuApplication.appContext.getString(descriptionId) + } + return@lazy descriptionString + } + + val isEditable: Boolean + get() { + // Can't change docked mode toggle when using handheld mode + if (setting.key == BooleanSetting.USE_DOCKED_MODE.key) { + return NativeInput.getStyleIndex(0) != NpadStyleIndex.Handheld + } + + // Can't edit settings that aren't saveable in per-game config even if they are switchable + if (NativeConfig.isPerGameConfigLoaded() && !setting.isSaveable) { + return false + } + + if (!NativeLibrary.isRunning()) return true + + // Prevent editing settings that were modified in per-game config while editing global + // config + if (!NativeConfig.isPerGameConfigLoaded() && !setting.global) { + return false + } + + return setting.isRuntimeModifiable + } + + val needsRuntimeGlobal: Boolean + get() = NativeLibrary.isRunning() && !setting.global && + !NativeConfig.isPerGameConfigLoaded() + + val clearable: Boolean + get() = !setting.global && NativeConfig.isPerGameConfigLoaded() + + companion object { + const val TYPE_HEADER = 0 + const val TYPE_SWITCH = 1 + const val TYPE_SINGLE_CHOICE = 2 + const val TYPE_SLIDER = 3 + const val TYPE_SUBMENU = 4 + const val TYPE_STRING_SINGLE_CHOICE = 5 + const val TYPE_DATETIME_SETTING = 6 + const val TYPE_RUNNABLE = 7 + const val TYPE_INPUT = 8 + const val TYPE_INT_SINGLE_CHOICE = 9 + const val TYPE_INPUT_PROFILE = 10 + const val TYPE_STRING_INPUT = 11 + + const val FASTMEM_COMBINED = "fastmem_combined" + + val emptySetting = object : AbstractSetting { + override val key: String = "" + override val defaultValue: Any = false + override val isSaveable = true + override fun getValueAsString(needsGlobal: Boolean): String = "" + override fun reset() {} + } + + // Extension for putting SettingsItems into a hashmap without repeating yourself + fun HashMap.put(item: SettingsItem) { + put(item.setting.key, item) + } + + // List of all general + val settingsItems = HashMap().apply { + put(StringInputSetting(StringSetting.DEVICE_NAME, titleId = R.string.device_name)) + put( + SwitchSetting( + BooleanSetting.RENDERER_USE_SPEED_LIMIT, + titleId = R.string.frame_limit_enable, + descriptionId = R.string.frame_limit_enable_description + ) + ) + put( + SliderSetting( + ShortSetting.RENDERER_SPEED_LIMIT, + titleId = R.string.frame_limit_slider, + descriptionId = R.string.frame_limit_slider_description, + min = 1, + max = 400, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.CPU_BACKEND, + titleId = R.string.cpu_backend, + choicesId = R.array.cpuBackendArm64Names, + valuesId = R.array.cpuBackendArm64Values + ) + ) + put( + SingleChoiceSetting( + IntSetting.CPU_ACCURACY, + titleId = R.string.cpu_accuracy, + choicesId = R.array.cpuAccuracyNames, + valuesId = R.array.cpuAccuracyValues + ) + ) + put( + SwitchSetting( + BooleanSetting.PICTURE_IN_PICTURE, + titleId = R.string.picture_in_picture, + descriptionId = R.string.picture_in_picture_description + ) + ) + + val dockedModeSetting = object : AbstractBooleanSetting { + override val key = BooleanSetting.USE_DOCKED_MODE.key + + override fun getBoolean(needsGlobal: Boolean): Boolean { + if (NativeInput.getStyleIndex(0) == NpadStyleIndex.Handheld) { + return false + } + return BooleanSetting.USE_DOCKED_MODE.getBoolean(needsGlobal) + } + + override fun setBoolean(value: Boolean) = + BooleanSetting.USE_DOCKED_MODE.setBoolean(value) + + override val defaultValue = BooleanSetting.USE_DOCKED_MODE.defaultValue + + override fun getValueAsString(needsGlobal: Boolean): String = + BooleanSetting.USE_DOCKED_MODE.getValueAsString(needsGlobal) + + override fun reset() = BooleanSetting.USE_DOCKED_MODE.reset() + } + put( + SwitchSetting( + dockedModeSetting, + titleId = R.string.use_docked_mode, + descriptionId = R.string.use_docked_mode_description + ) + ) + + put( + SingleChoiceSetting( + IntSetting.REGION_INDEX, + titleId = R.string.emulated_region, + choicesId = R.array.regionNames, + valuesId = R.array.regionValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.LANGUAGE_INDEX, + titleId = R.string.emulated_language, + choicesId = R.array.languageNames, + valuesId = R.array.languageValues + ) + ) + put( + SwitchSetting( + BooleanSetting.USE_CUSTOM_RTC, + titleId = R.string.use_custom_rtc, + descriptionId = R.string.use_custom_rtc_description + ) + ) + put(DateTimeSetting(LongSetting.CUSTOM_RTC, titleId = R.string.set_custom_rtc)) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ACCURACY, + titleId = R.string.renderer_accuracy, + choicesId = R.array.rendererAccuracyNames, + valuesId = R.array.rendererAccuracyValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_RESOLUTION, + titleId = R.string.renderer_resolution, + choicesId = R.array.rendererResolutionNames, + valuesId = R.array.rendererResolutionValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_VSYNC, + titleId = R.string.renderer_vsync, + choicesId = R.array.rendererVSyncNames, + valuesId = R.array.rendererVSyncValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_SCALING_FILTER, + titleId = R.string.renderer_scaling_filter, + choicesId = R.array.rendererScalingFilterNames, + valuesId = R.array.rendererScalingFilterValues + ) + ) + put( + SliderSetting( + IntSetting.FSR_SHARPENING_SLIDER, + titleId = R.string.fsr_sharpness, + descriptionId = R.string.fsr_sharpness_description, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ANTI_ALIASING, + titleId = R.string.renderer_anti_aliasing, + choicesId = R.array.rendererAntiAliasingNames, + valuesId = R.array.rendererAntiAliasingValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_SCREEN_LAYOUT, + titleId = R.string.renderer_screen_layout, + choicesId = R.array.rendererScreenLayoutNames, + valuesId = R.array.rendererScreenLayoutValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_ASPECT_RATIO, + titleId = R.string.renderer_aspect_ratio, + choicesId = R.array.rendererAspectRatioNames, + valuesId = R.array.rendererAspectRatioValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.VERTICAL_ALIGNMENT, + titleId = R.string.vertical_alignment, + descriptionId = 0, + choicesId = R.array.verticalAlignmentEntries, + valuesId = R.array.verticalAlignmentValues + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE, + titleId = R.string.use_disk_shader_cache, + descriptionId = R.string.use_disk_shader_cache_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_FORCE_MAX_CLOCK, + titleId = R.string.renderer_force_max_clock, + descriptionId = R.string.renderer_force_max_clock_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS, + titleId = R.string.renderer_asynchronous_shaders, + descriptionId = R.string.renderer_asynchronous_shaders_description + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_REACTIVE_FLUSHING, + titleId = R.string.renderer_reactive_flushing, + descriptionId = R.string.renderer_reactive_flushing_description + ) + ) + put( + SingleChoiceSetting( + IntSetting.MAX_ANISOTROPY, + titleId = R.string.anisotropic_filtering, + descriptionId = R.string.anisotropic_filtering_description, + choicesId = R.array.anisoEntries, + valuesId = R.array.anisoValues + ) + ) + put( + SingleChoiceSetting( + IntSetting.AUDIO_OUTPUT_ENGINE, + titleId = R.string.audio_output_engine, + choicesId = R.array.outputEngineEntries, + valuesId = R.array.outputEngineValues + ) + ) + put( + SliderSetting( + ByteSetting.AUDIO_VOLUME, + titleId = R.string.audio_volume, + descriptionId = R.string.audio_volume_description, + units = "%" + ) + ) + put( + SingleChoiceSetting( + IntSetting.RENDERER_BACKEND, + titleId = R.string.renderer_api, + choicesId = R.array.rendererApiNames, + valuesId = R.array.rendererApiValues + ) + ) + put( + SwitchSetting( + BooleanSetting.RENDERER_DEBUG, + titleId = R.string.renderer_debug, + descriptionId = R.string.renderer_debug_description + ) + ) + put( + SwitchSetting( + BooleanSetting.CPU_DEBUG_MODE, + titleId = R.string.cpu_debug_mode, + descriptionId = R.string.cpu_debug_mode_description + ) + ) + + val fastmem = object : AbstractBooleanSetting { + override fun getBoolean(needsGlobal: Boolean): Boolean = + BooleanSetting.FASTMEM.getBoolean() && + BooleanSetting.FASTMEM_EXCLUSIVES.getBoolean() + + override fun setBoolean(value: Boolean) { + BooleanSetting.FASTMEM.setBoolean(value) + BooleanSetting.FASTMEM_EXCLUSIVES.setBoolean(value) + } + + override val key: String = FASTMEM_COMBINED + override val isRuntimeModifiable: Boolean = false + override val pairedSettingKey = BooleanSetting.CPU_DEBUG_MODE.key + override val defaultValue: Boolean = true + override val isSwitchable: Boolean = true + override var global: Boolean + get() { + return BooleanSetting.FASTMEM.global && + BooleanSetting.FASTMEM_EXCLUSIVES.global + } + set(value) { + BooleanSetting.FASTMEM.global = value + BooleanSetting.FASTMEM_EXCLUSIVES.global = value + } + + override val isSaveable = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean().toString() + + override fun reset() = setBoolean(defaultValue) + } + put(SwitchSetting(fastmem, R.string.fastmem)) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt new file mode 100644 index 000000000..ea5e099ed --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SingleChoiceSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + @ArrayRes val choicesId: Int, + @ArrayRes val valuesId: Int +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SINGLE_CHOICE + + fun getSelectedValue(needsGlobal: Boolean = false) = + when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) + else -> -1 + } + + fun setSelectedValue(value: Int) = (setting as AbstractIntSetting).setInt(value) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SliderSetting.kt new file mode 100644 index 000000000..6a5cdf48b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SliderSetting.kt @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractByteSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractShortSetting +import kotlin.math.roundToInt + +class SliderSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val min: Int = 0, + val max: Int = 100, + val units: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SLIDER + + fun getSelectedValue(needsGlobal: Boolean = false) = + when (setting) { + is AbstractByteSetting -> setting.getByte(needsGlobal).toInt() + is AbstractShortSetting -> setting.getShort(needsGlobal).toInt() + is AbstractIntSetting -> setting.getInt(needsGlobal) + is AbstractFloatSetting -> setting.getFloat(needsGlobal).roundToInt() + else -> -1 + } + + fun setSelectedValue(value: Int) = + when (setting) { + is AbstractByteSetting -> setting.setByte(value.toByte()) + is AbstractShortSetting -> setting.setShort(value.toShort()) + is AbstractFloatSetting -> setting.setFloat(value.toFloat()) + else -> (setting as AbstractIntSetting).setInt(value) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringInputSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringInputSetting.kt new file mode 100644 index 000000000..1eb999416 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringInputSetting.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting + +class StringInputSetting( + setting: AbstractStringSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_STRING_INPUT + + fun getSelectedValue(needsGlobal: Boolean = false) = setting.getValueAsString(needsGlobal) + + fun setSelectedValue(selection: String) = + (setting as AbstractStringSetting).setString(selection) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt new file mode 100644 index 000000000..5260ff4dc --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting + +class StringSingleChoiceSetting( + private val stringSetting: AbstractStringSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + val choices: Array, + val values: Array +) : SettingsItem(stringSetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_STRING_SINGLE_CHOICE + + fun getValueAt(index: Int): String = + if (index >= 0 && index < values.size) values[index] else "" + + fun getSelectedValue(needsGlobal: Boolean = false) = stringSetting.getString(needsGlobal) + fun setSelectedValue(value: String) = stringSetting.setString(value) + + val selectedValueIndex: Int + get() { + for (i in values.indices) { + if (values[i] == getSelectedValue()) { + return i + } + } + return -1 + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SubmenuSetting.kt new file mode 100644 index 000000000..c722393dd --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SubmenuSetting.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.Settings + +class SubmenuSetting( + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "", + @DrawableRes val iconId: Int = 0, + val menuKey: Settings.MenuTag +) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SUBMENU +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SwitchSetting.kt new file mode 100644 index 000000000..4984bf52e --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/model/view/SwitchSetting.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.model.view + +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting + +class SwitchSetting( + setting: AbstractSetting, + @StringRes titleId: Int = 0, + titleString: String = "", + @StringRes descriptionId: Int = 0, + descriptionString: String = "" +) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { + override val type = TYPE_SWITCH + + fun getIsChecked(needsGlobal: Boolean = false): Boolean { + return when (setting) { + is AbstractIntSetting -> setting.getInt(needsGlobal) == 1 + is AbstractBooleanSetting -> setting.getBoolean(needsGlobal) + else -> false + } + } + + fun setChecked(value: Boolean) { + when (setting) { + is AbstractIntSetting -> setting.setInt(if (value) 1 else 0) + is AbstractBooleanSetting -> setting.setBoolean(value) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputDialogFragment.kt new file mode 100644 index 000000000..16a1d0504 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputDialogFragment.kt @@ -0,0 +1,300 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.graphics.drawable.Animatable2 +import android.graphics.drawable.AnimatedVectorDrawable +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogMappingBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.ParamPackage + +class InputDialogFragment : DialogFragment() { + private var inputAccepted = false + + private var position: Int = 0 + + private lateinit var inputSetting: InputSetting + + private lateinit var binding: DialogMappingBinding + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (settingsViewModel.clickedItem == null) dismiss() + + position = requireArguments().getInt(POSITION) + + InputHandler.updateControllerData() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + inputSetting = settingsViewModel.clickedItem as InputSetting + binding = DialogMappingBinding.inflate(layoutInflater) + + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(android.R.string.cancel) { _, _ -> + NativeInput.stopMapping() + dismiss() + } + .setView(binding.root) + + val playButtonMapAnimation = { twoDirections: Boolean -> + val stickAnimation: AnimatedVectorDrawable + val buttonAnimation: AnimatedVectorDrawable + binding.imageStickAnimation.apply { + val anim = if (twoDirections) { + R.drawable.stick_two_direction_anim + } else { + R.drawable.stick_one_direction_anim + } + setBackgroundResource(anim) + stickAnimation = background as AnimatedVectorDrawable + } + binding.imageButtonAnimation.apply { + setBackgroundResource(R.drawable.button_anim) + buttonAnimation = background as AnimatedVectorDrawable + } + stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + buttonAnimation.start() + } + }) + buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { + override fun onAnimationEnd(drawable: Drawable?) { + stickAnimation.start() + } + }) + stickAnimation.start() + } + + when (val setting = inputSetting) { + is AnalogInputSetting -> { + when (setting.nativeAnalog) { + NativeAnalog.LStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.left_stick)) + ) + + NativeAnalog.RStick -> builder.setTitle( + getString(R.string.map_control, getString(R.string.right_stick)) + ) + } + + builder.setMessage(R.string.stick_map_description) + + playButtonMapAnimation.invoke(true) + } + + is ModifierInputSetting -> { + builder.setTitle(getString(R.string.map_control, setting.title)) + .setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + + is ButtonInputSetting -> { + if (setting.nativeButton == NativeButton.DUp || + setting.nativeButton == NativeButton.DDown || + setting.nativeButton == NativeButton.DLeft || + setting.nativeButton == NativeButton.DRight + ) { + builder.setTitle(getString(R.string.map_dpad_direction, setting.title)) + } else { + builder.setTitle(getString(R.string.map_control, setting.title)) + } + builder.setMessage(R.string.button_map_description) + playButtonMapAnimation.invoke(false) + } + } + + return builder.create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.requestFocus() + view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } + dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) } + binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) } + NativeInput.beginMapping(inputSetting.inputType.int) + } + + private fun onKeyEvent(event: KeyEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + onInputReceived(event.device) + return true + } + + private fun onMotionEvent(event: MotionEvent): Boolean { + if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && + event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD + ) { + return false + } + + // Temp workaround for DPads that give both axis and button input. The input system can't + // take in a specific axis direction for a binding so you lose half of the directions for a DPad. + + val controllerData = + InputHandler.androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + onInputReceived(event.device) + } + return true + } + + private fun onInputReceived(device: InputDevice) { + val params = ParamPackage(NativeInput.getNextInput()) + if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) { + inputAccepted = true + setResult(params, device) + } + } + + private fun setResult(params: ParamPackage, device: InputDevice) { + NativeInput.stopMapping() + params.set("display", "${device.name} ${params.get("port", 0)}") + when (val item = settingsViewModel.clickedItem as InputSetting) { + is ModifierInputSetting, + is ButtonInputSetting -> { + // Invert DPad up and left bindings by default + val tempSetting = inputSetting as? ButtonInputSetting + if (tempSetting != null) { + if (tempSetting.nativeButton == NativeButton.DUp || + tempSetting.nativeButton == NativeButton.DLeft && + params.has("axis") + ) { + params.set("invert", "-") + } + } + + item.setSelectedValue(params) + settingsViewModel.setAdapterItemChanged(position) + } + + is AnalogInputSetting -> { + var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param) + + // Invert Y-Axis by default + analogParam.set("invert_y", "-") + + item.setSelectedValue(analogParam) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + } + dismiss() + } + + private fun adjustAnalogParam( + inputParam: ParamPackage, + analogParam: ParamPackage, + buttonName: String + ): ParamPackage { + // The poller returned a complete axis, so set all the buttons + if (inputParam.has("axis_x") && inputParam.has("axis_y")) { + return inputParam + } + + // Check if the current configuration has either no engine or an axis binding. + // Clears out the old binding and adds one with analog_from_button. + if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) { + analogParam.clear() + analogParam.set("engine", "analog_from_button") + } + analogParam.set(buttonName, inputParam.serialize()) + return analogParam + } + + private fun isInputAcceptable(params: ParamPackage): Boolean { + if (InputHandler.registeredControllers.size == 1) { + return true + } + + if (params.has("motion")) { + return true + } + + val currentDevice = settingsViewModel.getCurrentDeviceParams(params) + if (currentDevice.get("engine", "any") == "any") { + return true + } + + val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") || + params.get("guid", "") == currentDevice.get("guid2", "") + return params.get("engine", "") == currentDevice.get("engine", "") && + guidMatch && + params.get("port", 0) == currentDevice.get("port", 0) + } + + companion object { + const val TAG = "InputDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + inputMappingViewModel: SettingsViewModel, + setting: InputSetting, + position: Int + ): InputDialogFragment { + inputMappingViewModel.clickedItem = setting + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileAdapter.kt new file mode 100644 index 000000000..5656e9d8d --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileAdapter.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.AbstractListAdapter +import org.yuzu.yuzu_emu.databinding.ListItemInputProfileBinding +import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder +import org.yuzu.yuzu_emu.R + +class InputProfileAdapter(options: List) : + AbstractListAdapter>(options) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): AbstractViewHolder { + ListItemInputProfileBinding.inflate(LayoutInflater.from(parent.context), parent, false) + .also { return InputProfileViewHolder(it) } + } + + inner class InputProfileViewHolder(val binding: ListItemInputProfileBinding) : + AbstractViewHolder(binding) { + override fun bind(model: ProfileItem) { + when (model) { + is ExistingProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.GONE + binding.buttonDelete.visibility = View.VISIBLE + binding.buttonDelete.setOnClickListener { model.deleteProfile.invoke() } + binding.buttonSave.visibility = View.VISIBLE + binding.buttonSave.setOnClickListener { model.saveProfile.invoke() } + binding.buttonLoad.visibility = View.VISIBLE + binding.buttonLoad.setOnClickListener { model.loadProfile.invoke() } + } + + is NewProfileItem -> { + binding.title.text = model.name + binding.buttonNew.visibility = View.VISIBLE + binding.buttonNew.setOnClickListener { model.createNewProfile.invoke() } + binding.buttonSave.visibility = View.GONE + binding.buttonDelete.visibility = View.GONE + binding.buttonLoad.visibility = View.GONE + } + } + } + } +} + +sealed interface ProfileItem { + val name: String +} + +data class NewProfileItem( + val createNewProfile: () -> Unit +) : ProfileItem { + override val name: String = YuzuApplication.appContext.getString(R.string.create_new_profile) +} + +data class ExistingProfileItem( + override val name: String, + val deleteProfile: () -> Unit, + val saveProfile: () -> Unit, + val loadProfile: () -> Unit +) : ProfileItem diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt new file mode 100644 index 000000000..1bae593ae --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +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.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogInputProfilesBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.utils.collect + +class InputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogInputProfilesBinding + + private lateinit var setting: InputProfileSetting + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogInputProfilesBinding.inflate(layoutInflater) + + setting = settingsViewModel.clickedItem as InputProfileSetting + val options = mutableListOf().apply { + add( + NewProfileItem( + createNewProfile = { + NewInputProfileDialogFragment.newInstance( + settingsViewModel, + setting, + position + ).show(parentFragmentManager, NewInputProfileDialogFragment.TAG) + dismiss() + } + ) + ) + + val onActionDismiss = { + settingsViewModel.setReloadListAndNotifyDataset(true) + dismiss() + } + setting.getProfileNames().forEach { + add( + ExistingProfileItem( + it, + deleteProfile = { + settingsViewModel.setShouldShowDeleteProfileDialog(it) + }, + saveProfile = { + if (!setting.saveProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_save_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + }, + loadProfile = { + if (!setting.loadProfile(it)) { + Toast.makeText( + requireContext(), + R.string.failed_to_load_profile, + Toast.LENGTH_SHORT + ).show() + } + onActionDismiss.invoke() + } + ) + ) + } + } + binding.listProfiles.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = InputProfileAdapter(options) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .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) + + settingsViewModel.shouldShowDeleteProfileDialog.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.delete_input_profile, + descriptionId = R.string.delete_input_profile_description, + positiveAction = { + setting.deleteProfile(it) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {}, + negativeButtonTitleId = android.R.string.cancel + ).show(parentFragmentManager, MessageDialogFragment.TAG) + settingsViewModel.setShouldShowDeleteProfileDialog("") + dismiss() + } + } + } + + companion object { + const val TAG = "InputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): InputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = InputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt new file mode 100644 index 000000000..6e52bea80 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.R + +class NewInputProfileDialogFragment : DialogFragment() { + private var position = 0 + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var binding: DialogEditTextBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + position = requireArguments().getInt(POSITION) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogEditTextBinding.inflate(layoutInflater) + + val setting = settingsViewModel.clickedItem as InputProfileSetting + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.enter_profile_name) + .setPositiveButton(android.R.string.ok) { _, _ -> + val profileName = binding.editText.text.toString() + if (!setting.isProfileNameValid(profileName)) { + Toast.makeText( + requireContext(), + R.string.invalid_profile_name, + Toast.LENGTH_SHORT + ).show() + return@setPositiveButton + } + + if (!setting.createProfile(profileName)) { + Toast.makeText( + requireContext(), + R.string.profile_name_already_exists, + Toast.LENGTH_SHORT + ).show() + } else { + settingsViewModel.setAdapterItemChanged(position) + } + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "NewInputProfileDialogFragment" + + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + profileSetting: InputProfileSetting, + position: Int + ): NewInputProfileDialogFragment { + settingsViewModel.clickedItem = profileSetting + + val args = Bundle() + args.putInt(POSITION, position) + val fragment = NewInputProfileDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsActivity.kt new file mode 100644 index 000000000..455b3b5ff --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsActivity.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.navArgs +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.IOException +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment +import org.yuzu.yuzu_emu.utils.* + +class SettingsActivity : AppCompatActivity() { + private lateinit var binding: ActivitySettingsBinding + + private val args by navArgs() + + private val settingsViewModel: SettingsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) { + SettingsFile.loadCustomConfig(args.game!!) + } + settingsViewModel.game = args.game + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + navHostFragment.navController.setGraph(R.navigation.settings_navigation, intent.extras) + + WindowCompat.setDecorFitsSystemWindows(window, false) + + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.navigationBarShade, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + settingsViewModel.shouldRecreate.collect( + this, + resetState = { settingsViewModel.setShouldRecreate(false) } + ) { if (it) recreate() } + settingsViewModel.shouldNavigateBack.collect( + this, + resetState = { settingsViewModel.setShouldNavigateBack(false) } + ) { if (it) navigateBack() } + settingsViewModel.shouldShowResetSettingsDialog.collect( + this, + resetState = { settingsViewModel.setShouldShowResetSettingsDialog(false) } + ) { + if (it) { + ResetSettingsDialogFragment().show( + supportFragmentManager, + ResetSettingsDialogFragment.TAG + ) + } + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() = navigateBack() + } + ) + + setInsets() + } + + fun navigateBack() { + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + if (navHostFragment.childFragmentManager.backStackEntryCount > 0) { + navHostFragment.navController.popBackStack() + } else { + finish() + } + } + + override fun onStart() { + super.onStart() + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + } + + override fun onStop() { + super.onStop() + Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...") + if (isFinishing) { + NativeInput.reloadInputDevices() + NativeLibrary.applySettings() + if (args.game == null) { + NativeConfig.saveGlobalConfig() + } else if (NativeConfig.isPerGameConfigLoaded()) { + NativeLibrary.logSettings() + NativeConfig.savePerGameConfig() + NativeConfig.unloadPerGameConfig() + } + } + } + + fun onSettingsReset() { + // Delete settings file because the user may have changed values that do not exist in the UI + if (args.game == null) { + NativeConfig.unloadGlobalConfig() + val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + NativeConfig.initializeGlobalConfig() + } else { + NativeConfig.unloadPerGameConfig() + val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!) + if (!settingsFile.delete()) { + throw IOException("Failed to delete $settingsFile") + } + } + + Toast.makeText( + applicationContext, + getString(R.string.settings_reset), + Toast.LENGTH_LONG + ).show() + finish() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.navigationBarShade + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = barInsets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsAdapter.kt new file mode 100644 index 000000000..500ac6e66 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsAdapter.kt @@ -0,0 +1,434 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.navigation.findNavController +import androidx.recyclerview.widget.AsyncDifferConfig +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.SettingsNavigationDirections +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* +import org.yuzu.yuzu_emu.utils.ParamPackage + +class SettingsAdapter( + private val fragment: Fragment, + private val context: Context +) : ListAdapter( + AsyncDifferConfig.Builder(DiffCallback()).build() +) { + private val settingsViewModel: SettingsViewModel + get() = ViewModelProvider(fragment.requireActivity())[SettingsViewModel::class.java] + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + SettingsItem.TYPE_HEADER -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SWITCH -> { + SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SLIDER -> { + SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_SUBMENU -> { + SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_DATETIME_SETTING -> { + DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_RUNNABLE -> { + RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INPUT -> { + InputViewHolder(ListItemSettingInputBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_INPUT_PROFILE -> { + InputProfileViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + SettingsItem.TYPE_STRING_INPUT -> { + StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this) + } + + else -> { + HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) + } + } + } + + override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { + holder.bind(currentList[position]) + } + + override fun getItemCount(): Int = currentList.size + + override fun getItemViewType(position: Int): Int { + return currentList[position].type + } + + fun onBooleanClick(item: SwitchSetting, checked: Boolean, position: Int) { + item.setChecked(checked) + notifyItemChanged(position) + settingsViewModel.setShouldReloadSettingsList(true) + } + + fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_STRING_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onIntSingleChoiceClick(item: IntSingleChoiceSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_INT_SINGLE_CHOICE, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onDateTimeClick(item: DateTimeSetting, position: Int) { + val storedTime = item.getValue() * 1000 + + // Helper to extract hour and minute from epoch time + val calendar: Calendar = Calendar.getInstance() + calendar.timeInMillis = storedTime + calendar.timeZone = TimeZone.getTimeZone("UTC") + + var timeFormat: Int = TimeFormat.CLOCK_12H + if (DateFormat.is24HourFormat(context)) { + timeFormat = TimeFormat.CLOCK_24H + } + + val datePicker: MaterialDatePicker = MaterialDatePicker.Builder.datePicker() + .setSelection(storedTime) + .setTitleText(R.string.select_rtc_date) + .build() + val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() + .setTimeFormat(timeFormat) + .setHour(calendar.get(Calendar.HOUR_OF_DAY)) + .setMinute(calendar.get(Calendar.MINUTE)) + .setTitleText(R.string.select_rtc_time) + .build() + + datePicker.addOnPositiveButtonClickListener { + timePicker.show( + fragment.childFragmentManager, + "TimePicker" + ) + } + timePicker.addOnPositiveButtonClickListener { + var epochTime: Long = datePicker.selection!! / 1000 + epochTime += timePicker.hour.toLong() * 60 * 60 + epochTime += timePicker.minute.toLong() * 60 + if (item.getValue() != epochTime) { + notifyItemChanged(position) + item.setValue(epochTime) + } + } + datePicker.show( + fragment.childFragmentManager, + "DatePicker" + ) + } + + fun onSliderClick(item: SliderSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_SLIDER, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onSubmenuClick(item: SubmenuSetting) { + val action = SettingsNavigationDirections.actionGlobalSettingsFragment(item.menuKey, null) + fragment.view?.findNavController()?.navigate(action) + } + + fun onInputProfileClick(item: InputProfileSetting, position: Int) { + InputProfileDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputProfileDialogFragment.TAG) + } + + fun onInputClick(item: InputSetting, position: Int) { + InputDialogFragment.newInstance( + settingsViewModel, + item, + position + ).show(fragment.childFragmentManager, InputDialogFragment.TAG) + } + + fun onInputOptionsClick(anchor: View, item: InputSetting, position: Int) { + val popup = PopupMenu(context, anchor) + popup.menuInflater.inflate(R.menu.menu_input_options, popup.menu) + + popup.menu.apply { + val invertAxis = findItem(R.id.invert_axis) + val invertButton = findItem(R.id.invert_button) + val toggleButton = findItem(R.id.toggle_button) + val turboButton = findItem(R.id.turbo_button) + val setThreshold = findItem(R.id.set_threshold) + val toggleAxis = findItem(R.id.toggle_axis) + when (item) { + is AnalogInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = when (item.analogDirection) { + AnalogDirection.Left, AnalogDirection.Right -> { + params.get("invert_x", "+") == "-" + } + + AnalogDirection.Up, AnalogDirection.Down -> { + params.get("invert_y", "+") == "-" + } + } + invertAxis.setOnMenuItemClickListener { + if (item.analogDirection == AnalogDirection.Left || + item.analogDirection == AnalogDirection.Right + ) { + val invertValue = params.get("invert_x", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_x", invertString) + } else if ( + item.analogDirection == AnalogDirection.Up || + item.analogDirection == AnalogDirection.Down + ) { + val invertValue = params.get("invert_y", "+") == "-" + val invertString = if (invertValue) "+" else "-" + params.set("invert_y", invertString) + } + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam(item.playerIndex, item.nativeAnalog, params) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + val params = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + if (params.has("code") || params.has("button") || params.has("hat")) { + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + val toggle = params.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + params.set("toggle", !toggle) + true + } + + val turbo = params.get("turbo", false) + turboButton.isVisible = true + turboButton.isCheckable = true + turboButton.isChecked = turbo + turboButton.setOnMenuItemClickListener { + params.set("turbo", !turbo) + true + } + } else if (params.has("axis")) { + val axisInvert = params.get("invert", "+") == "-" + invertAxis.isVisible = true + invertAxis.isCheckable = true + invertAxis.isChecked = axisInvert + invertAxis.setOnMenuItemClickListener { + params.set("invert", if (!axisInvert) "-" else "+") + true + } + + val buttonInvert = params.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = buttonInvert + invertButton.setOnMenuItemClickListener { + params.set("inverted", !buttonInvert) + true + } + + setThreshold.isVisible = true + val thresholdSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get("threshold", 0.5f) * 100).toInt() + + override fun setInt(value: Int) { + params.set("threshold", value.toFloat() / 100) + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + params + ) + } + + override val defaultValue = 50 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + } + setThreshold.setOnMenuItemClickListener { + onSliderClick( + SliderSetting(thresholdSetting, R.string.set_threshold), + position + ) + true + } + + val axisToggle = params.get("toggle", false) + toggleAxis.isVisible = true + toggleAxis.isCheckable = true + toggleAxis.isChecked = axisToggle + toggleAxis.setOnMenuItemClickListener { + params.set("toggle", !axisToggle) + true + } + } + + popup.setOnDismissListener { + NativeInput.setButtonParam(item.playerIndex, item.nativeButton, params) + settingsViewModel.setAdapterItemChanged(position) + } + } + + is ModifierInputSetting -> { + val stickParams = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + val modifierParams = ParamPackage(stickParams.get("modifier", "")) + + val invert = modifierParams.get("inverted", false) + invertButton.isVisible = true + invertButton.isCheckable = true + invertButton.isChecked = invert + invertButton.setOnMenuItemClickListener { + modifierParams.set("inverted", !invert) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + val toggle = modifierParams.get("toggle", false) + toggleButton.isVisible = true + toggleButton.isCheckable = true + toggleButton.isChecked = toggle + toggleButton.setOnMenuItemClickListener { + modifierParams.set("toggle", !toggle) + stickParams.set("modifier", modifierParams.serialize()) + true + } + + popup.setOnDismissListener { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParams + ) + settingsViewModel.setAdapterItemChanged(position) + } + } + } + } + popup.show() + } + + fun onStringInputClick(item: StringInputSetting, position: Int) { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsItem.TYPE_STRING_INPUT, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + } + + fun onLongClick(item: SettingsItem, position: Int): Boolean { + SettingsDialogFragment.newInstance( + settingsViewModel, + item, + SettingsDialogFragment.TYPE_RESET_SETTING, + position + ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) + + return true + } + + fun onClearClick(item: SettingsItem, position: Int) { + item.setting.global = true + notifyItemChanged(position) + settingsViewModel.setShouldReloadSettingsList(true) + } + + private class DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { + return oldItem.setting.key == newItem.setting.key + } + + override fun areContentsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { + return oldItem.setting.key == newItem.setting.key + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt new file mode 100644 index 000000000..7f562a1f4 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt @@ -0,0 +1,301 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding +import org.yuzu.yuzu_emu.databinding.DialogSliderBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.IntSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting +import org.yuzu.yuzu_emu.utils.ParamPackage +import org.yuzu.yuzu_emu.utils.collect + +class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener { + private var type = 0 + private var position = 0 + + private var defaultCancelListener = + DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } + + private val settingsViewModel: SettingsViewModel by activityViewModels() + + private lateinit var sliderBinding: DialogSliderBinding + private lateinit var stringInputBinding: DialogEditTextBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + type = requireArguments().getInt(TYPE) + position = requireArguments().getInt(POSITION) + + if (settingsViewModel.clickedItem == null) dismiss() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return when (type) { + TYPE_RESET_SETTING -> { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.reset_setting_confirmation) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (val item = settingsViewModel.clickedItem) { + is AnalogInputSetting -> { + val stickParam = NativeInput.getStickParam( + item.playerIndex, + item.nativeAnalog + ) + if (stickParam.get("engine", "") == "analog_from_button") { + when (item.analogDirection) { + AnalogDirection.Up -> stickParam.erase("up") + AnalogDirection.Down -> stickParam.erase("down") + AnalogDirection.Left -> stickParam.erase("left") + AnalogDirection.Right -> stickParam.erase("right") + } + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + stickParam + ) + settingsViewModel.setAdapterItemChanged(position) + } else { + NativeInput.setStickParam( + item.playerIndex, + item.nativeAnalog, + ParamPackage() + ) + settingsViewModel.setDatasetChanged(true) + } + } + + is ButtonInputSetting -> { + NativeInput.setButtonParam( + item.playerIndex, + item.nativeButton, + ParamPackage() + ) + settingsViewModel.setAdapterItemChanged(position) + } + + else -> { + settingsViewModel.clickedItem!!.setting.reset() + settingsViewModel.setAdapterItemChanged(position) + } + } + } + .setNegativeButton(android.R.string.cancel, null) + .create() + } + + SettingsItem.TYPE_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getSelectionForSingleChoiceValue(item) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choicesId, value, this) + .create() + } + + SettingsItem.TYPE_SLIDER -> { + sliderBinding = DialogSliderBinding.inflate(layoutInflater) + val item = settingsViewModel.clickedItem as SliderSetting + + settingsViewModel.setSliderTextValue(item.getSelectedValue().toFloat(), item.units) + sliderBinding.slider.apply { + valueFrom = item.min.toFloat() + valueTo = item.max.toFloat() + value = settingsViewModel.sliderProgress.value.toFloat() + addOnChangeListener { _: Slider, value: Float, _: Boolean -> + settingsViewModel.setSliderTextValue(value, item.units) + } + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setView(sliderBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .create() + } + + SettingsItem.TYPE_STRING_INPUT -> { + stringInputBinding = DialogEditTextBinding.inflate(layoutInflater) + val item = settingsViewModel.clickedItem as StringInputSetting + stringInputBinding.editText.setText(item.getSelectedValue()) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setView(stringInputBinding.root) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, defaultCancelListener) + .create() + } + + SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as StringSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + SettingsItem.TYPE_INT_SINGLE_CHOICE -> { + val item = settingsViewModel.clickedItem as IntSingleChoiceSetting + MaterialAlertDialogBuilder(requireContext()) + .setTitle(item.title) + .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) + .create() + } + + else -> super.onCreateDialog(savedInstanceState) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return when (type) { + SettingsItem.TYPE_SLIDER -> sliderBinding.root + SettingsItem.TYPE_STRING_INPUT -> stringInputBinding.root + else -> super.onCreateView(inflater, container, savedInstanceState) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + when (type) { + SettingsItem.TYPE_SLIDER -> { + settingsViewModel.sliderTextValue.collect(viewLifecycleOwner) { + sliderBinding.textValue.text = it + } + settingsViewModel.sliderProgress.collect(viewLifecycleOwner) { + sliderBinding.slider.value = it.toFloat() + } + } + } + } + + override fun onClick(dialog: DialogInterface, which: Int) { + when (settingsViewModel.clickedItem) { + is SingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting + val value = getValueForSingleChoiceSelection(scSetting, which) + scSetting.setSelectedValue(value) + } + + is StringSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is IntSingleChoiceSetting -> { + val scSetting = settingsViewModel.clickedItem as IntSingleChoiceSetting + val value = scSetting.getValueAt(which) + scSetting.setSelectedValue(value) + } + + is SliderSetting -> { + val sliderSetting = settingsViewModel.clickedItem as SliderSetting + sliderSetting.setSelectedValue(settingsViewModel.sliderProgress.value) + } + + is StringInputSetting -> { + val stringInputSetting = settingsViewModel.clickedItem as StringInputSetting + stringInputSetting.setSelectedValue( + (stringInputBinding.editText.text ?: "").toString() + ) + } + } + closeDialog() + } + + private fun closeDialog() { + settingsViewModel.setAdapterItemChanged(position) + settingsViewModel.clickedItem = null + settingsViewModel.setSliderProgress(-1f) + dismiss() + } + + private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { + val valuesId = item.valuesId + return if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + valuesArray[which] + } else { + which + } + } + + private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { + val value = item.getSelectedValue() + val valuesId = item.valuesId + if (valuesId > 0) { + val valuesArray = requireContext().resources.getIntArray(valuesId) + for (index in valuesArray.indices) { + val current = valuesArray[index] + if (current == value) { + return index + } + } + } else { + return value + } + return -1 + } + + companion object { + const val TAG = "SettingsDialogFragment" + + const val TYPE_RESET_SETTING = -1 + + const val TITLE = "Title" + const val TYPE = "Type" + const val POSITION = "Position" + + fun newInstance( + settingsViewModel: SettingsViewModel, + clickedItem: SettingsItem, + type: Int, + position: Int + ): SettingsDialogFragment { + when (type) { + SettingsItem.TYPE_HEADER, + SettingsItem.TYPE_SWITCH, + SettingsItem.TYPE_SUBMENU, + SettingsItem.TYPE_DATETIME_SETTING, + SettingsItem.TYPE_RUNNABLE -> + throw IllegalArgumentException("[SettingsDialogFragment] Incompatible type!") + + SettingsItem.TYPE_SLIDER -> settingsViewModel.setSliderProgress( + (clickedItem as SliderSetting).getSelectedValue().toFloat() + ) + } + settingsViewModel.clickedItem = clickedItem + + val args = Bundle() + args.putInt(TYPE, type) + args.putInt(POSITION, position) + val fragment = SettingsDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragment.kt new file mode 100644 index 000000000..ec16f16c4 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragment.kt @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect + +class SettingsFragment : Fragment() { + private lateinit var presenter: SettingsFragmentPresenter + private var settingsAdapter: SettingsAdapter? = null + + private var _binding: FragmentSettingsBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private val settingsViewModel: SettingsViewModel 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) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + + val playerIndex = getPlayerIndex() + if (playerIndex != -1) { + NativeInput.loadInputProfiles() + NativeInput.reloadInputDevices() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsBinding.inflate(layoutInflater) + return binding.root + } + + @SuppressLint("NotifyDataSetChanged") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settingsAdapter = SettingsAdapter(this, requireContext()) + presenter = SettingsFragmentPresenter( + settingsViewModel, + settingsAdapter!!, + args.menuTag + ) + + binding.toolbarSettingsLayout.title = if (args.menuTag == Settings.MenuTag.SECTION_ROOT && + args.game != null + ) { + args.game!!.title + } else { + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> Settings.getPlayerString(1) + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> Settings.getPlayerString(2) + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> Settings.getPlayerString(3) + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> Settings.getPlayerString(4) + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> Settings.getPlayerString(5) + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> Settings.getPlayerString(6) + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> Settings.getPlayerString(7) + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> Settings.getPlayerString(8) + else -> getString(args.menuTag.titleId) + } + } + binding.listSettings.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(requireContext()) + } + + binding.toolbarSettings.setNavigationOnClickListener { + settingsViewModel.setShouldNavigateBack(true) + } + + settingsViewModel.shouldReloadSettingsList.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setShouldReloadSettingsList(false) } + ) { if (it) presenter.loadSettingsList() } + settingsViewModel.adapterItemChanged.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setAdapterItemChanged(-1) } + ) { if (it != -1) settingsAdapter?.notifyItemChanged(it) } + settingsViewModel.datasetChanged.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setDatasetChanged(false) } + ) { if (it) settingsAdapter?.notifyDataSetChanged() } + settingsViewModel.reloadListAndNotifyDataset.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setReloadListAndNotifyDataset(false) } + ) { if (it) presenter.loadSettingsList(true) } + settingsViewModel.shouldShowResetInputDialog.collect( + viewLifecycleOwner, + resetState = { settingsViewModel.setShouldShowResetInputDialog(false) } + ) { + if (it) { + MessageDialogFragment.newInstance( + activity = requireActivity(), + titleId = R.string.reset_mapping, + descriptionId = R.string.reset_mapping_description, + positiveAction = { + NativeInput.resetControllerMappings(getPlayerIndex()) + settingsViewModel.setReloadListAndNotifyDataset(true) + }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + if (args.menuTag == Settings.MenuTag.SECTION_ROOT) { + binding.toolbarSettings.inflateMenu(R.menu.menu_settings) + binding.toolbarSettings.setOnMenuItemClickListener { + when (it.itemId) { + R.id.action_search -> { + view.findNavController() + .navigate(R.id.action_settingsFragment_to_settingsSearchFragment) + true + } + + else -> false + } + } + } + + presenter.onViewCreated() + + setInsets() + } + + private fun getPlayerIndex(): Int = + when (args.menuTag) { + Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> 0 + Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> 1 + Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> 2 + Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> 3 + Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> 4 + Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> 5 + Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> 6 + Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> 7 + else -> -1 + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.listSettings.updateMargins(left = leftInsets, right = rightInsets) + binding.listSettings.updatePadding(bottom = barInsets.bottom) + + binding.appbarSettings.updateMargins(left = leftInsets, right = rightInsets) + windowInsets + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt new file mode 100644 index 000000000..3ea5f5008 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt @@ -0,0 +1,975 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.annotation.SuppressLint +import android.os.Build +import android.widget.Toast +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.model.AnalogDirection +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex +import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.ByteSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.LongSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.Settings.MenuTag +import org.yuzu.yuzu_emu.features.settings.model.ShortSetting +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.model.view.* +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.NativeConfig + +class SettingsFragmentPresenter( + private val settingsViewModel: SettingsViewModel, + private val adapter: SettingsAdapter, + private var menuTag: MenuTag +) { + private var settingsList = ArrayList() + + private val context get() = YuzuApplication.appContext + + // Extension for altering settings list based on each setting's properties + fun ArrayList.add(key: String) { + val item = SettingsItem.settingsItems[key]!! + if (settingsViewModel.game != null && !item.setting.isSwitchable) { + return + } + + if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) { + item.setting.global = true + } + + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean( + pairedSettingKey, + if (NativeLibrary.isRunning() && !NativeConfig.isPerGameConfigLoaded()) { + !NativeConfig.usingGlobal(pairedSettingKey) + } else { + NativeConfig.usingGlobal(pairedSettingKey) + } + ) + if (!pairedSettingValue) return + } + add(item) + } + + // Allows you to show/hide abstract settings based on the paired setting key + fun ArrayList.addAbstract(item: SettingsItem) { + val pairedSettingKey = item.setting.pairedSettingKey + if (pairedSettingKey.isNotEmpty()) { + val pairedSettingsItem = + this.firstOrNull { it.setting.key == pairedSettingKey } ?: return + val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting + if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return + } + add(item) + } + + fun onViewCreated() { + loadSettingsList() + } + + @SuppressLint("NotifyDataSetChanged") + fun loadSettingsList(notifyDataSetChanged: Boolean = false) { + val sl = ArrayList() + when (menuTag) { + MenuTag.SECTION_ROOT -> addConfigSettings(sl) + MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) + MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) + MenuTag.SECTION_AUDIO -> addAudioSettings(sl) + MenuTag.SECTION_INPUT -> addInputSettings(sl) + MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0) + MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1) + MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2) + MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3) + MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4) + MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5) + MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6) + MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) + MenuTag.SECTION_THEME -> addThemeSettings(sl) + MenuTag.SECTION_DEBUG -> addDebugSettings(sl) + } + settingsList = sl + adapter.submitList(settingsList) { + if (notifyDataSetChanged) { + adapter.notifyDataSetChanged() + } + } + } + + private fun addConfigSettings(sl: ArrayList) { + sl.apply { + add( + SubmenuSetting( + titleId = R.string.preferences_system, + descriptionId = R.string.preferences_system_description, + iconId = R.drawable.ic_system_settings, + menuKey = MenuTag.SECTION_SYSTEM + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_graphics, + descriptionId = R.string.preferences_graphics_description, + iconId = R.drawable.ic_graphics, + menuKey = MenuTag.SECTION_RENDERER + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_audio, + descriptionId = R.string.preferences_audio_description, + iconId = R.drawable.ic_audio, + menuKey = MenuTag.SECTION_AUDIO + ) + ) + add( + SubmenuSetting( + titleId = R.string.preferences_debug, + descriptionId = R.string.preferences_debug_description, + iconId = R.drawable.ic_code, + menuKey = MenuTag.SECTION_DEBUG + ) + ) + add( + RunnableSetting( + titleId = R.string.reset_to_default, + descriptionId = R.string.reset_to_default_description, + isRunnable = !NativeLibrary.isRunning(), + iconId = R.drawable.ic_restore + ) { settingsViewModel.setShouldShowResetSettingsDialog(true) } + ) + } + } + + private fun addSystemSettings(sl: ArrayList) { + sl.apply { + add(StringSetting.DEVICE_NAME.key) + add(BooleanSetting.RENDERER_USE_SPEED_LIMIT.key) + add(ShortSetting.RENDERER_SPEED_LIMIT.key) + add(BooleanSetting.USE_DOCKED_MODE.key) + add(IntSetting.REGION_INDEX.key) + add(IntSetting.LANGUAGE_INDEX.key) + add(BooleanSetting.USE_CUSTOM_RTC.key) + add(LongSetting.CUSTOM_RTC.key) + } + } + + private fun addGraphicsSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.RENDERER_ACCURACY.key) + add(IntSetting.RENDERER_RESOLUTION.key) + add(IntSetting.RENDERER_VSYNC.key) + add(IntSetting.RENDERER_SCALING_FILTER.key) + add(IntSetting.FSR_SHARPENING_SLIDER.key) + add(IntSetting.RENDERER_ANTI_ALIASING.key) + add(IntSetting.MAX_ANISOTROPY.key) + add(IntSetting.RENDERER_SCREEN_LAYOUT.key) + add(IntSetting.RENDERER_ASPECT_RATIO.key) + add(IntSetting.VERTICAL_ALIGNMENT.key) + add(BooleanSetting.PICTURE_IN_PICTURE.key) + add(BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE.key) + add(BooleanSetting.RENDERER_FORCE_MAX_CLOCK.key) + add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) + add(BooleanSetting.RENDERER_REACTIVE_FLUSHING.key) + } + } + + private fun addAudioSettings(sl: ArrayList) { + sl.apply { + add(IntSetting.AUDIO_OUTPUT_ENGINE.key) + add(ByteSetting.AUDIO_VOLUME.key) + } + } + + private fun addInputSettings(sl: ArrayList) { + settingsViewModel.currentDevice = 0 + + if (NativeConfig.isPerGameConfigLoaded()) { + NativeInput.loadInputProfiles() + val profiles = NativeInput.getInputProfileNames().toMutableList() + profiles.add(0, "") + val prettyProfiles = profiles.toTypedArray() + prettyProfiles[0] = + context.getString(R.string.use_global_input_configuration) + sl.apply { + for (i in 0 until 8) { + add( + IntSingleChoiceSetting( + getPerGameProfileSetting(profiles, i), + titleString = getPlayerProfileString(i + 1), + choices = prettyProfiles, + values = IntArray(profiles.size) { it }.toTypedArray() + ) + ) + } + } + return + } + + val getConnectedIcon: (Int) -> Int = { playerIndex: Int -> + if (NativeInput.getIsConnected(playerIndex)) { + R.drawable.ic_controller + } else { + R.drawable.ic_controller_disconnected + } + } + + val inputSettings = NativeConfig.getInputSettings(true) + sl.apply { + add( + SubmenuSetting( + titleString = Settings.getPlayerString(1), + descriptionString = inputSettings[0].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE, + iconId = getConnectedIcon(0) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(2), + descriptionString = inputSettings[1].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO, + iconId = getConnectedIcon(1) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(3), + descriptionString = inputSettings[2].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE, + iconId = getConnectedIcon(2) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(4), + descriptionString = inputSettings[3].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR, + iconId = getConnectedIcon(3) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(5), + descriptionString = inputSettings[4].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE, + iconId = getConnectedIcon(4) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(6), + descriptionString = inputSettings[5].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX, + iconId = getConnectedIcon(5) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(7), + descriptionString = inputSettings[6].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN, + iconId = getConnectedIcon(6) + ) + ) + add( + SubmenuSetting( + titleString = Settings.getPlayerString(8), + descriptionString = inputSettings[7].profileName, + menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT, + iconId = getConnectedIcon(7) + ) + ) + } + } + + private fun getPlayerProfileString(player: Int): String = + context.getString(R.string.player_num_profile, player) + + private fun getPerGameProfileSetting( + profiles: List, + playerIndex: Int + ): AbstractIntSetting { + return object : AbstractIntSetting { + private val players + get() = NativeConfig.getInputSettings(false) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int { + val currentProfile = players[playerIndex].profileName + profiles.forEachIndexed { i, profile -> + if (profile == currentProfile) { + return i + } + } + return 0 + } + + override fun setInt(value: Int) { + NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value]) + NativeInput.connectControllers(playerIndex) + NativeConfig.saveControlPlayerValues() + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override var global = true + + override val isRuntimeModifiable = true + + override val isSaveable = true + } + } + + private fun addInputPlayer(sl: ArrayList, playerIndex: Int) { + sl.apply { + val connectedSetting = object : AbstractBooleanSetting { + override val key = "connected" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeInput.getIsConnected(playerIndex) + + override fun setBoolean(value: Boolean) = + NativeInput.connectControllers(playerIndex, value) + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(connectedSetting, R.string.connected)) + + val styleTags = NativeInput.getSupportedStyleTags(playerIndex) + val npadType = object : AbstractIntSetting { + override val key = "npad_type" + override fun getInt(needsGlobal: Boolean): Int { + val styleIndex = NativeInput.getStyleIndex(playerIndex) + return styleTags.indexOfFirst { it == styleIndex } + } + + override fun setInt(value: Int) { + NativeInput.setStyleIndex(playerIndex, styleTags[value]) + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = NpadStyleIndex.Fullkey.int + override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() + override fun reset() = setInt(defaultValue) + override val pairedSettingKey: String = "connected" + } + addAbstract( + IntSingleChoiceSetting( + npadType, + titleId = R.string.controller_type, + choices = styleTags.map { context.getString(it.nameId) } + .toTypedArray(), + values = IntArray(styleTags.size) { it }.toTypedArray() + ) + ) + + InputHandler.updateControllerData() + + val autoMappingSetting = object : AbstractIntSetting { + override val key = "auto_mapping_device" + + override fun getInt(needsGlobal: Boolean): Int = -1 + + override fun setInt(value: Int) { + val registeredController = InputHandler.registeredControllers[value + 1] + val displayName = registeredController.get( + "display", + context.getString(R.string.unknown) + ) + NativeInput.updateMappingsWithDefault( + playerIndex, + registeredController, + displayName + ) + Toast.makeText( + context, + context.getString(R.string.attempted_auto_map, displayName), + Toast.LENGTH_SHORT + ).show() + settingsViewModel.setReloadListAndNotifyDataset(true) + } + + override val defaultValue = -1 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val unknownString = context.getString(R.string.unknown) + val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull { + val port = it.get("port", -1) + return@mapNotNull if (port == 100 || port == -1) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + autoMappingSetting, + titleId = R.string.auto_map, + descriptionId = R.string.auto_map_description, + choices = prettyAutoMappingControllerList, + values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray() + ) + ) + + val mappingFilterSetting = object : AbstractIntSetting { + override val key = "mapping_filter" + + override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice + + override fun setInt(value: Int) { + settingsViewModel.currentDevice = value + } + + override val defaultValue = 0 + + override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() + + override fun reset() = setInt(defaultValue) + + override val isRuntimeModifiable: Boolean = true + } + + val prettyControllerList = InputHandler.registeredControllers.mapNotNull { + return@mapNotNull if (it.get("port", 0) == 100) { + null + } else { + it.get("display", unknownString) + } + }.toTypedArray() + add( + IntSingleChoiceSetting( + mappingFilterSetting, + titleId = R.string.input_mapping_filter, + descriptionId = R.string.input_mapping_filter_description, + choices = prettyControllerList, + values = IntArray(prettyControllerList.size) { it }.toTypedArray() + ) + ) + + add(InputProfileSetting(playerIndex)) + add( + RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) { + settingsViewModel.setShouldShowResetInputDialog(true) + } + ) + + val styleIndex = NativeInput.getStyleIndex(playerIndex) + + // Buttons + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.Capture, + R.string.button_capture + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) + add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.buttons)) + add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) + add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) + add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) + add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) + add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause)) + } + + else -> { + // No-op + } + } + + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.dpad)) + add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up)) + add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down)) + add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left)) + add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right)) + } + + else -> { + // No-op + } + } + + // Left stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.left_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.control_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) + } + + else -> { + // No-op + } + } + + // Right stick + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld, + NpadStyleIndex.JoyconDual, + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.right_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.c_stick)) + addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) + addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) + } + + else -> { + // No-op + } + } + + // L/R, ZL/ZR, and SL/SR + when (styleIndex) { + NpadStyleIndex.Fullkey, + NpadStyleIndex.Handheld -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + } + + NpadStyleIndex.JoyconDual -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.JoyconLeft -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLLeft, + R.string.button_sl_left + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRLeft, + R.string.button_sr_left + ) + ) + } + + NpadStyleIndex.JoyconRight -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SLRight, + R.string.button_sl_right + ) + ) + add( + ButtonInputSetting( + playerIndex, + NativeButton.SRRight, + R.string.button_sr_right + ) + ) + } + + NpadStyleIndex.GameCube -> { + add(HeaderSetting(R.string.triggers)) + add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z)) + add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l)) + add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r)) + } + + else -> { + // No-op + } + } + + add(HeaderSetting(R.string.vibration)) + val vibrationEnabledSetting = object : AbstractBooleanSetting { + override val key = "vibration" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationEnabled = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = true + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + } + add(SwitchSetting(vibrationEnabledSetting, R.string.vibration)) + + val useSystemVibratorSetting = object : AbstractBooleanSetting { + override val key = "" + + override fun getBoolean(needsGlobal: Boolean): Boolean = + NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator + + override fun setBoolean(value: Boolean) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].useSystemVibrator = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = playerIndex == 0 + + override fun getValueAsString(needsGlobal: Boolean): String = + getBoolean(needsGlobal).toString() + + override fun reset() = setBoolean(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator)) + + val vibrationStrengthSetting = object : AbstractIntSetting { + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength + + override fun setInt(value: Int) { + val settings = NativeConfig.getInputSettings(true) + settings[playerIndex].vibrationStrength = value + NativeConfig.setInputSettings(settings, true) + } + + override val defaultValue = 100 + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(defaultValue) + + override val pairedSettingKey: String = "vibration" + } + addAbstract( + SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%") + ) + } + } + + // Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones + private fun getStickIntSettingFromParam( + playerIndex: Int, + paramName: String, + stick: NativeAnalog, + defaultValue: Float + ): AbstractIntSetting = + object : AbstractIntSetting { + val params get() = NativeInput.getStickParam(playerIndex, stick) + + override val key = "" + + override fun getInt(needsGlobal: Boolean): Int = + (params.get(paramName, defaultValue) * 100).toInt() + + override fun setInt(value: Int) { + val tempParams = params + tempParams.set(paramName, value.toFloat() / 100) + NativeInput.setStickParam(playerIndex, stick, tempParams) + } + + override val defaultValue = (defaultValue * 100).toInt() + + override fun getValueAsString(needsGlobal: Boolean): String = + getInt(needsGlobal).toString() + + override fun reset() = setInt(this.defaultValue) + } + + private fun getExtraStickSettings( + playerIndex: Int, + nativeAnalog: NativeAnalog + ): List { + val stickIsController = + NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog)) + val modifierRangeSetting = + getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 0.5f) + val stickRangeSetting = + getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 0.95f) + val stickDeadzoneSetting = + getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 0.15f) + + val out = mutableListOf().apply { + if (stickIsController) { + add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150)) + add(SliderSetting(stickDeadzoneSetting, R.string.deadzone)) + } else { + add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier)) + add(SliderSetting(modifierRangeSetting, R.string.modifier_range)) + } + } + return out + } + + private fun getStickDirections(player: Int, stick: NativeAnalog): List = + listOf( + AnalogInputSetting( + player, + stick, + AnalogDirection.Up, + R.string.up + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Down, + R.string.down + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Left, + R.string.left + ), + AnalogInputSetting( + player, + stick, + AnalogDirection.Right, + R.string.right + ) + ) + + private fun addThemeSettings(sl: ArrayList) { + sl.apply { + val theme: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME.getInt() + override fun setInt(value: Int) { + IntSetting.THEME.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME.key + override val isRuntimeModifiable: Boolean = IntSetting.THEME.isRuntimeModifiable + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME.defaultValue + override fun reset() = IntSetting.THEME.setInt(defaultValue) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntriesA12, + valuesId = R.array.themeValuesA12 + ) + ) + } else { + add( + SingleChoiceSetting( + theme, + titleId = R.string.change_app_theme, + choicesId = R.array.themeEntries, + valuesId = R.array.themeValues + ) + ) + } + + val themeMode: AbstractIntSetting = object : AbstractIntSetting { + override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME_MODE.getInt() + override fun setInt(value: Int) { + IntSetting.THEME_MODE.setInt(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = IntSetting.THEME_MODE.key + override val isRuntimeModifiable: Boolean = + IntSetting.THEME_MODE.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + IntSetting.THEME_MODE.getValueAsString() + + override val defaultValue: Int = IntSetting.THEME_MODE.defaultValue + override fun reset() { + IntSetting.THEME_MODE.setInt(defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SingleChoiceSetting( + themeMode, + titleId = R.string.change_theme_mode, + choicesId = R.array.themeModeEntries, + valuesId = R.array.themeModeValues + ) + ) + + val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { + override fun getBoolean(needsGlobal: Boolean): Boolean = + BooleanSetting.BLACK_BACKGROUNDS.getBoolean() + + override fun setBoolean(value: Boolean) { + BooleanSetting.BLACK_BACKGROUNDS.setBoolean(value) + settingsViewModel.setShouldRecreate(true) + } + + override val key: String = BooleanSetting.BLACK_BACKGROUNDS.key + override val isRuntimeModifiable: Boolean = + BooleanSetting.BLACK_BACKGROUNDS.isRuntimeModifiable + + override fun getValueAsString(needsGlobal: Boolean): String = + BooleanSetting.BLACK_BACKGROUNDS.getValueAsString() + + override val defaultValue: Boolean = BooleanSetting.BLACK_BACKGROUNDS.defaultValue + override fun reset() { + BooleanSetting.BLACK_BACKGROUNDS + .setBoolean(BooleanSetting.BLACK_BACKGROUNDS.defaultValue) + settingsViewModel.setShouldRecreate(true) + } + } + + add( + SwitchSetting( + blackBackgrounds, + titleId = R.string.use_black_backgrounds, + descriptionId = R.string.use_black_backgrounds_description + ) + ) + } + } + + private fun addDebugSettings(sl: ArrayList) { + sl.apply { + add(HeaderSetting(R.string.gpu)) + add(IntSetting.RENDERER_BACKEND.key) + add(BooleanSetting.RENDERER_DEBUG.key) + + add(HeaderSetting(R.string.cpu)) + add(IntSetting.CPU_BACKEND.key) + add(IntSetting.CPU_ACCURACY.key) + add(BooleanSetting.CPU_DEBUG_MODE.key) + add(SettingsItem.FASTMEM_COMBINED) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt new file mode 100644 index 000000000..ed60cf34f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +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.recyclerview.widget.LinearLayoutManager +import com.google.android.material.divider.MaterialDividerItemDecoration +import com.google.android.material.transition.MaterialSharedAxis +import info.debatty.java.stringsimilarity.Cosine +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentSettingsSearchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect + +class SettingsSearchFragment : Fragment() { + private var _binding: FragmentSettingsSearchBinding? = null + private val binding get() = _binding!! + + private var settingsAdapter: SettingsAdapter? = null + + private val settingsViewModel: SettingsViewModel 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) + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSettingsSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + settingsAdapter = SettingsAdapter(this, requireContext()) + + val dividerDecoration = MaterialDividerItemDecoration( + requireContext(), + LinearLayoutManager.VERTICAL + ) + dividerDecoration.isLastItemDecorated = false + binding.settingsList.apply { + adapter = settingsAdapter + layoutManager = LinearLayoutManager(requireContext()) + addItemDecoration(dividerDecoration) + } + + focusSearch() + + binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) } + binding.searchBackground.setOnClickListener { focusSearch() } + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + binding.searchText.doOnTextChanged { _, _, _, _ -> + search() + binding.settingsList.smoothScrollToPosition(0) + } + settingsViewModel.shouldReloadSettingsList.collect(viewLifecycleOwner) { + if (it) { + settingsViewModel.setShouldReloadSettingsList(false) + search() + } + } + + search() + + setInsets() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + + private fun search() { + val searchTerm = binding.searchText.text.toString().lowercase() + binding.clearButton.setVisible(visible = searchTerm.isNotEmpty(), gone = false) + if (searchTerm.isEmpty()) { + binding.noResultsView.setVisible(visible = false, gone = false) + settingsAdapter?.submitList(emptyList()) + return + } + + val baseList = SettingsItem.settingsItems + val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1) + val sortedList: List = baseList.mapNotNull { item -> + val title = item.value.title.lowercase() + val similarity = similarityAlgorithm.similarity(searchTerm, title) + if (similarity > 0.08) { + Pair(similarity, item) + } else { + null + } + }.sortedByDescending { it.first }.mapNotNull { + val item = it.second.value + val pairedSettingKey = item.setting.pairedSettingKey + val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) { + val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false) + if (pairedSettingValue) it.second.value else null + } else { + it.second.value + } + optionalSetting + } + settingsAdapter?.submitList(sortedList) + binding.noResultsView.setVisible(visible = sortedList.isEmpty(), gone = false) + } + + private fun focusSearch() { + 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, windowInsets: WindowInsetsCompat -> + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge) + val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing) + binding.frameSearch.updatePadding( + left = leftInsets + sideMargin, + top = barInsets.top + topMargin, + right = rightInsets + sideMargin + ) + binding.noResultsView.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + binding.settingsList.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + binding.divider.updateMargins( + left = leftInsets + sideMargin, + right = rightInsets + sideMargin + ) + + windowInsets + } + + companion object { + const val SEARCH_TEXT = "SearchText" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsViewModel.kt new file mode 100644 index 000000000..fbdca04e9 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/SettingsViewModel.kt @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.InputHandler +import org.yuzu.yuzu_emu.utils.ParamPackage + +class SettingsViewModel : ViewModel() { + var game: Game? = null + + var clickedItem: SettingsItem? = null + + var currentDevice = 0 + + val shouldRecreate: StateFlow get() = _shouldRecreate + private val _shouldRecreate = MutableStateFlow(false) + + val shouldNavigateBack: StateFlow get() = _shouldNavigateBack + private val _shouldNavigateBack = MutableStateFlow(false) + + val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog + private val _shouldShowResetSettingsDialog = MutableStateFlow(false) + + val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList + private val _shouldReloadSettingsList = MutableStateFlow(false) + + val sliderProgress: StateFlow get() = _sliderProgress + private val _sliderProgress = MutableStateFlow(-1) + + val sliderTextValue: StateFlow get() = _sliderTextValue + private val _sliderTextValue = MutableStateFlow("") + + val adapterItemChanged: StateFlow get() = _adapterItemChanged + private val _adapterItemChanged = MutableStateFlow(-1) + + private val _datasetChanged = MutableStateFlow(false) + val datasetChanged = _datasetChanged.asStateFlow() + + private val _reloadListAndNotifyDataset = MutableStateFlow(false) + val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow() + + private val _shouldShowDeleteProfileDialog = MutableStateFlow("") + val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow() + + private val _shouldShowResetInputDialog = MutableStateFlow(false) + val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow() + + fun setShouldRecreate(value: Boolean) { + _shouldRecreate.value = value + } + + fun setShouldNavigateBack(value: Boolean) { + _shouldNavigateBack.value = value + } + + fun setShouldShowResetSettingsDialog(value: Boolean) { + _shouldShowResetSettingsDialog.value = value + } + + fun setShouldReloadSettingsList(value: Boolean) { + _shouldReloadSettingsList.value = value + } + + fun setSliderTextValue(value: Float, units: String) { + _sliderProgress.value = value.toInt() + _sliderTextValue.value = String.format( + YuzuApplication.appContext.getString(R.string.value_with_units), + value.toInt().toString(), + units + ) + } + + fun setSliderProgress(value: Float) { + _sliderProgress.value = value.toInt() + } + + fun setAdapterItemChanged(value: Int) { + _adapterItemChanged.value = value + } + + fun setDatasetChanged(value: Boolean) { + _datasetChanged.value = value + } + + fun setReloadListAndNotifyDataset(value: Boolean) { + _reloadListAndNotifyDataset.value = value + } + + fun setShouldShowDeleteProfileDialog(profile: String) { + _shouldShowDeleteProfileDialog.value = profile + } + + fun setShouldShowResetInputDialog(value: Boolean) { + _shouldShowResetInputDialog.value = value + } + + fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage = + try { + InputHandler.registeredControllers[currentDevice] + } catch (e: IndexOutOfBoundsException) { + defaultParams + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt new file mode 100644 index 000000000..0309fad59 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: DateTimeSetting + + override fun bind(item: SettingsItem) { + setting = item as DateTimeSetting + binding.textSettingName.text = item.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + binding.textSettingValue.setVisible(true) + val epochTime = setting.getValue() + val instant = Instant.ofEpochMilli(epochTime * 1000) + val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) + val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + binding.textSettingValue.text = dateFormatter.format(zonedTime) + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onDateTimeClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt new file mode 100644 index 000000000..0815c36e2 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + init { + itemView.setOnClickListener(null) + } + + override fun bind(item: SettingsItem) { + binding.textHeaderName.text = item.title + } + + override fun onClick(clicked: View) { + // no-op + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt new file mode 100644 index 000000000..86af3a226 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputProfileSetting + + override fun bind(item: SettingsItem) { + setting = item as InputProfileSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = + setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) } + + binding.textSettingDescription.setVisible(false) + binding.buttonClear.setVisible(false) + binding.icon.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) = + adapter.onInputProfileClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = false +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt new file mode 100644 index 000000000..9d9047804 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class InputViewHolder(val binding: ListItemSettingInputBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: InputSetting + + override fun bind(item: SettingsItem) { + setting = item as InputSetting + binding.textSettingName.text = setting.title + binding.textSettingValue.text = setting.getSelectedValue() + + when (item) { + is AnalogInputSetting -> { + val param = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + binding.buttonOptions.setVisible( + param.get("engine", "") == "analog_from_button" || + param.has("axis_x") || param.has("axis_y") + ) + } + + is ButtonInputSetting -> { + val param = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) + binding.buttonOptions.setVisible( + param.has("code") || param.has("button") || param.has("hat") || + param.has("axis") + ) + } + + is ModifierInputSetting -> { + val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) + binding.buttonOptions.setVisible(params.has("modifier")) + } + } + + binding.buttonOptions.setOnClickListener(null) + binding.buttonOptions.setOnClickListener { + adapter.onInputOptionsClick(binding.buttonOptions, setting, bindingAdapterPosition) + } + } + + override fun onClick(clicked: View) = + adapter.onInputClick(setting, bindingAdapterPosition) + + override fun onLongClick(clicked: View): Boolean = + adapter.onLongClick(setting, bindingAdapterPosition) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt new file mode 100644 index 000000000..fc2ffb515 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.core.content.res.ResourcesCompat +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: RunnableSetting + + override fun bind(item: SettingsItem) { + setting = item as RunnableSetting + binding.icon.setVisible(setting.iconId != 0) + if (setting.iconId != 0) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.resources, + setting.iconId, + binding.icon.context.theme + ) + ) + } + + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + binding.textSettingValue.setVisible(false) + binding.buttonClear.setVisible(false) + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isRunnable) { + setting.runnable.invoke() + } + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt new file mode 100644 index 000000000..d26887df8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter + +abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) : + RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener { + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + } + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + abstract fun bind(item: SettingsItem) + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + abstract override fun onClick(clicked: View) + + abstract override fun onLongClick(clicked: View): Boolean + + fun setStyle(isEditable: Boolean, binding: ListItemSettingBinding) { + val opacity = if (isEditable) 1.0f else 0.5f + binding.textSettingName.alpha = opacity + binding.textSettingDescription.alpha = opacity + binding.textSettingValue.alpha = opacity + binding.buttonClear.isEnabled = isEditable + } + + fun setStyle(isEditable: Boolean, binding: ListItemSettingSwitchBinding) { + binding.switchWidget.isEnabled = isEditable + val opacity = if (isEditable) 1.0f else 0.5f + binding.textSettingName.alpha = opacity + binding.textSettingDescription.alpha = opacity + binding.buttonClear.isEnabled = isEditable + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt new file mode 100644 index 000000000..489f55455 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.IntSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SettingsItem + + override fun bind(item: SettingsItem) { + setting = item + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = item.description + + binding.textSettingValue.setVisible(true) + when (item) { + is SingleChoiceSetting -> { + val resMgr = binding.textSettingValue.context.resources + val values = resMgr.getIntArray(item.valuesId) + for (i in values.indices) { + if (values[i] == item.getSelectedValue()) { + binding.textSettingValue.text = resMgr.getStringArray(item.choicesId)[i] + break + } + } + } + + is StringSingleChoiceSetting -> { + binding.textSettingValue.text = item.getSelectedValue() + } + + is IntSingleChoiceSetting -> { + binding.textSettingValue.text = item.getChoiceAt(item.getSelectedValue()) + } + } + if (binding.textSettingValue.text.isEmpty()) { + binding.textSettingValue.setVisible(false) + } + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (!setting.isEditable) { + return + } + + when (setting) { + is SingleChoiceSetting -> adapter.onSingleChoiceClick( + setting as SingleChoiceSetting, + bindingAdapterPosition + ) + + is StringSingleChoiceSetting -> { + adapter.onStringSingleChoiceClick( + setting as StringSingleChoiceSetting, + bindingAdapterPosition + ) + } + + is IntSingleChoiceSetting -> { + adapter.onIntSingleChoiceClick( + setting as IntSingleChoiceSetting, + bindingAdapterPosition + ) + } + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt new file mode 100644 index 000000000..90a7138cb --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SliderSetting + + override fun bind(item: SettingsItem) { + setting = item as SliderSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(item.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(true) + binding.textSettingValue.text = String.format( + binding.textSettingValue.context.getString(R.string.value_with_units), + setting.getSelectedValue(), + setting.units + ) + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onSliderClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt new file mode 100644 index 000000000..a4fd36f62 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.StringInputSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: StringInputSetting + + override fun bind(item: SettingsItem) { + setting = item as StringInputSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(true) + binding.textSettingValue.text = setting.getSelectedValue() + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + adapter.onStringInputClick(setting, bindingAdapterPosition) + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt new file mode 100644 index 000000000..f7a9c08c3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import androidx.core.content.res.ResourcesCompat +import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + private lateinit var setting: SubmenuSetting + + override fun bind(item: SettingsItem) { + setting = item as SubmenuSetting + binding.icon.setVisible(setting.iconId != 0) + if (setting.iconId != 0) { + binding.icon.setImageDrawable( + ResourcesCompat.getDrawable( + binding.icon.resources, + setting.iconId, + binding.icon.context.theme + ) + ) + } + + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + binding.textSettingValue.setVisible(false) + binding.buttonClear.setVisible(false) + } + + override fun onClick(clicked: View) { + adapter.onSubmenuClick(setting) + } + + override fun onLongClick(clicked: View): Boolean { + // no-op + return true + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt new file mode 100644 index 000000000..e5763264a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.ui.viewholder + +import android.view.View +import android.widget.CompoundButton +import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding +import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem +import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting +import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible + +class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) : + SettingViewHolder(binding.root, adapter) { + + private lateinit var setting: SwitchSetting + + override fun bind(item: SettingsItem) { + setting = item as SwitchSetting + binding.textSettingName.text = setting.title + binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) + binding.textSettingDescription.text = setting.description + + binding.switchWidget.setOnCheckedChangeListener(null) + binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal) + binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> + adapter.onBooleanClick(setting, binding.switchWidget.isChecked, bindingAdapterPosition) + } + + binding.buttonClear.setVisible(setting.clearable) + binding.buttonClear.setOnClickListener { + adapter.onClearClick(setting, bindingAdapterPosition) + } + + setStyle(setting.isEditable, binding) + } + + override fun onClick(clicked: View) { + if (setting.isEditable) { + binding.switchWidget.toggle() + } + } + + override fun onLongClick(clicked: View): Boolean { + if (setting.isEditable) { + return adapter.onLongClick(setting, bindingAdapterPosition) + } + return false + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/utils/SettingsFile.kt new file mode 100644 index 000000000..5d523be67 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/features/settings/utils/SettingsFile.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.features.settings.utils + +import android.net.Uri +import org.yuzu.yuzu_emu.model.Game +import java.io.* +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.NativeConfig + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +object SettingsFile { + const val FILE_NAME_CONFIG = "config.ini" + + fun getSettingsFile(fileName: String): File = + File(DirectoryInitialization.userDirectory + "/config/" + fileName) + + fun getCustomSettingsFile(game: Game): File = + File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini") + + fun loadCustomConfig(game: Game) { + val fileName = FileUtil.getFilename(Uri.parse(game.path)) + NativeConfig.initializePerGameConfig(game.programId, fileName) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AboutFragment.kt new file mode 100644 index 000000000..ff4f0e5df --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AboutFragment.kt @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.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.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +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.imageLogo.setOnLongClickListener { + Toast.makeText( + requireContext(), + R.string.gaia_is_not_real, + Toast.LENGTH_SHORT + ).show() + true + } + + binding.buttonContributors.setOnClickListener { + openLink( + getString(R.string.contributors_link) + ) + } + binding.buttonLicenses.setOnClickListener { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) + } + + binding.textVersionName.text = BuildConfig.VERSION_NAME + binding.buttonVersionName.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 + + binding.toolbarAbout.updateMargins(left = leftInsets, right = rightInsets) + binding.scrollAbout.updateMargins(left = leftInsets, right = rightInsets) + + binding.contentAbout.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddGameFolderDialogFragment.kt new file mode 100644 index 000000000..9fab88248 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddGameFolderDialogFragment.kt @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogAddFolderBinding +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel + +class AddGameFolderDialogFragment : DialogFragment() { + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogAddFolderBinding.inflate(layoutInflater) + val folderUriString = requireArguments().getString(FOLDER_URI_STRING) + if (folderUriString == null) { + dismiss() + } + binding.path.text = Uri.parse(folderUriString).path + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.add_game_folder) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) + homeViewModel.setGamesDirSelected(true) + gamesViewModel.addFolder(newGameDir) + } + .setNegativeButton(android.R.string.cancel, null) + .setView(binding.root) + .show() + } + + companion object { + const val TAG = "AddGameFolderDialogFragment" + + private const val FOLDER_URI_STRING = "FolderUriString" + + fun newInstance(folderUriString: String): AddGameFolderDialogFragment { + val args = Bundle() + args.putString(FOLDER_URI_STRING, folderUriString) + val fragment = AddGameFolderDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddonsFragment.kt new file mode 100644 index 000000000..110aa2960 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AddonsFragment.kt @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.AddonAdapter +import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.AddonUtil +import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect +import java.io.File + +class AddonsFragment : Fragment() { + private var _binding: FragmentAddonsBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val addonViewModel: AddonViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + addonViewModel.onOpenAddons(args.game) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAddonsBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.toolbarAddons.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) + + binding.listAddons.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = AddonAdapter(addonViewModel) + } + + addonViewModel.addonList.collect(viewLifecycleOwner) { + (binding.listAddons.adapter as AddonAdapter).submitList(it) + } + addonViewModel.showModInstallPicker.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModInstallPicker(false) } + ) { if (it) installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) } + addonViewModel.showModNoticeDialog.collect( + viewLifecycleOwner, + resetState = { addonViewModel.showModNoticeDialog(false) } + ) { + if (it) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.addon_notice, + descriptionId = R.string.addon_notice_description, + dismissible = false, + positiveAction = { addonViewModel.showModInstallPicker(true) }, + negativeAction = {}, + negativeButtonTitleId = R.string.close + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + addonViewModel.addonToDelete.collect( + viewLifecycleOwner, + resetState = { addonViewModel.setAddonToDelete(null) } + ) { + if (it != null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.confirm_uninstall, + descriptionId = R.string.confirm_uninstall_description, + positiveAction = { addonViewModel.onDeleteAddon(it) }, + negativeAction = {} + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + binding.buttonInstall.setOnClickListener { + ContentTypeSelectionDialogFragment().show( + parentFragmentManager, + ContentTypeSelectionDialogFragment.TAG + ) + } + + setInsets() + } + + override fun onResume() { + super.onResume() + addonViewModel.refreshAddons() + } + + override fun onDestroy() { + super.onDestroy() + addonViewModel.onCloseAddons() + } + + val installAddon = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) + if (externalAddonDirectory == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + val isValid = externalAddonDirectory.listFiles() + .any { AddonUtil.validAddonDirectories.contains(it.name?.lowercase()) } + val errorMessage = MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.invalid_directory, + descriptionId = R.string.invalid_directory_description + ) + if (isValid) { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_game_content, + false + ) { progressCallback, _ -> + val parentDirectoryName = externalAddonDirectory.name + val internalAddonDirectory = + File(args.game.addonDir + parentDirectoryName) + try { + externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback) + } catch (_: Exception) { + return@newInstance errorMessage + } + addonViewModel.refreshAddons() + return@newInstance getString(R.string.addon_installed_successfully) + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } else { + errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updateMargins(left = leftInsets, right = rightInsets) + binding.listAddons.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonInstall.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AppletLauncherFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AppletLauncherFragment.kt new file mode 100644 index 000000000..73ca40484 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/AppletLauncherFragment.kt @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.AppletAdapter +import org.yuzu.yuzu_emu.databinding.FragmentAppletLauncherBinding +import org.yuzu.yuzu_emu.model.Applet +import org.yuzu.yuzu_emu.model.AppletInfo +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +class AppletLauncherFragment : Fragment() { + private var _binding: FragmentAppletLauncherBinding? = 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 = FragmentAppletLauncherBinding.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) + + binding.toolbarApplets.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val applets = listOf( + Applet( + R.string.album_applet, + R.string.album_applet_description, + R.drawable.ic_album, + AppletInfo.PhotoViewer + ), + Applet( + R.string.cabinet_applet, + R.string.cabinet_applet_description, + R.drawable.ic_nfc, + AppletInfo.Cabinet + ), + Applet( + R.string.mii_edit_applet, + R.string.mii_edit_applet_description, + R.drawable.ic_mii, + AppletInfo.MiiEdit + ) + ) + + binding.listApplets.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = AppletAdapter(requireActivity(), applets) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarApplets.updateMargins(left = leftInsets, right = rightInsets) + binding.listApplets.updateMargins(left = leftInsets, right = rightInsets) + + binding.listApplets.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt new file mode 100644 index 000000000..5933677fd --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.CabinetLauncherDialogAdapter +import org.yuzu.yuzu_emu.databinding.DialogListBinding + +class CabinetLauncherDialogFragment : DialogFragment() { + private lateinit var binding: DialogListBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogListBinding.inflate(layoutInflater) + binding.dialogList.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = CabinetLauncherDialogAdapter(this@CabinetLauncherDialogFragment) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.cabinet_launcher) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return binding.root + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt new file mode 100644 index 000000000..c1d8b9ea5 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity + +class ContentTypeSelectionDialogFragment : DialogFragment() { + private val addonViewModel: AddonViewModel by activityViewModels() + + private val preferences get() = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + private var selectedItem = 0 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val launchOptions = + arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + val mainActivity = requireActivity() as MainActivity + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.select_content_type) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + when (selectedItem) { + 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) + else -> { + if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { + preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() + addonViewModel.showModNoticeDialog(true) + return@setPositiveButton + } + addonViewModel.showModInstallPicker(true) + } + } + } + .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "ContentTypeSelectionDialogFragment" + + private const val SELECTED_ITEM = "SelectedItem" + private const val MOD_NOTICE_SEEN = "ModNoticeSeen" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CoreErrorDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CoreErrorDialogFragment.kt new file mode 100644 index 000000000..299f8da71 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/CoreErrorDialogFragment.kt @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R + +class CoreErrorDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(requireArguments().getString(TITLE)) + .setMessage(requireArguments().getString(MESSAGE)) + .setPositiveButton(R.string.continue_button, null) + .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> + NativeLibrary.coreErrorAlertResult = false + synchronized(NativeLibrary.coreErrorAlertLock) { + NativeLibrary.coreErrorAlertLock.notify() + } + } + .create() + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + NativeLibrary.coreErrorAlertResult = true + synchronized(NativeLibrary.coreErrorAlertLock) { NativeLibrary.coreErrorAlertLock.notify() } + } + + companion object { + const val TITLE = "Title" + const val MESSAGE = "Message" + + fun newInstance(title: String, message: String): CoreErrorDialogFragment { + val frag = CoreErrorDialogFragment() + val args = Bundle() + args.putString(TITLE, title) + args.putString(MESSAGE, message) + frag.arguments = args + return frag + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriverManagerFragment.kt new file mode 100644 index 000000000..8b23a1021 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriverManagerFragment.kt @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.DriverAdapter +import org.yuzu.yuzu_emu.databinding.FragmentDriverManagerBinding +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect +import java.io.File +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() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + 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) + + driverViewModel.onOpenDriverManager(args.game) + if (NativeConfig.isPerGameConfigLoaded()) { + binding.toolbarDrivers.inflateMenu(R.menu.menu_driver_manager) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + binding.toolbarDrivers.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_driver_use_global -> { + StringSetting.DRIVER_PATH.global = true + driverViewModel.updateDriverList() + (binding.listDrivers.adapter as DriverAdapter) + .replaceList(driverViewModel.driverList.value) + driverViewModel.showClearButton(false) + true + } + + else -> false + } + } + + driverViewModel.showClearButton.collect(viewLifecycleOwner) { + binding.toolbarDrivers.menu.findItem(R.id.menu_driver_use_global).isVisible = it + } + } + + if (!driverViewModel.isInteractionAllowed.value) { + 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.grid_columns) + ) + adapter = DriverAdapter(driverViewModel) + } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + driverViewModel.onCloseDriverManager(args.game) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarDrivers.updateMargins(left = leftInsets, right = rightInsets) + binding.listDrivers.updateMargins(left = leftInsets, right = rightInsets) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonInstall.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + 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 + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.installing_driver, + false + ) { _, _ -> + val driverPath = + "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}" + val driverFile = File(driverPath) + + // Ignore file exceptions when a user selects an invalid zip + try { + if (!GpuDriverHelper.copyDriverToInternalStorage(result)) { + throw IOException("Driver failed validation!") + } + } catch (_: IOException) { + if (driverFile.exists()) { + driverFile.delete() + } + return@newInstance getString(R.string.select_gpu_driver_error) + } + + val driverData = GpuDriverHelper.getMetadataFromZip(driverFile) + val driverInList = + driverViewModel.driverData.firstOrNull { it.second == driverData } + if (driverInList != null) { + return@newInstance getString(R.string.driver_already_installed) + } else { + driverViewModel.onDriverAdded(Pair(driverPath, driverData)) + withContext(Dispatchers.Main) { + if (_binding != null) { + val adapter = binding.listDrivers.adapter as DriverAdapter + adapter.addItem(driverData.toDriver()) + adapter.selectItem(adapter.currentList.indices.last) + driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) + binding.listDrivers + .smoothScrollToPosition(adapter.currentList.indices.last) + } + } + } + return@newInstance Any() + }.show(childFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriversLoadingDialogFragment.kt new file mode 100644 index 000000000..bad56e434 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/DriversLoadingDialogFragment.kt @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.utils.collect + +class DriversLoadingDialogFragment : DialogFragment() { + private val driverViewModel: DriverViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogProgressBarBinding.inflate(layoutInflater) + binding.progressBar.isIndeterminate = true + + isCancelable = false + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.loading) + .setView(binding.root) + .create() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = binding.root + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { if (it) dismiss() } + } + + companion object { + const val TAG = "DriversLoadingDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EarlyAccessFragment.kt new file mode 100644 index 000000000..0534b68ce --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EarlyAccessFragment.kt @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +class EarlyAccessFragment : Fragment() { + private var _binding: FragmentEarlyAccessBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEarlyAccessBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarAbout.setNavigationOnClickListener { + parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack() + } + + binding.getEarlyAccessButton.setOnClickListener { + openLink( + getString(R.string.play_store_link) + ) + } + + setInsets() + } + + private fun openLink(link: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + startActivity(intent) + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarEa.updateMargins(left = leftInsets, right = rightInsets) + + binding.scrollEa.updatePadding( + left = leftInsets, + right = rightInsets, + bottom = barInsets.bottom + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EmulationFragment.kt new file mode 100644 index 000000000..bcc880e17 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/EmulationFragment.kt @@ -0,0 +1,1048 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.util.Rational +import android.view.* +import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.widget.PopupMenu +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.slider.Slider +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding +import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationOrientation +import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationVerticalAlignment +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.EmulationViewModel +import org.yuzu.yuzu_emu.overlay.model.OverlayControl +import org.yuzu.yuzu_emu.overlay.model.OverlayLayout +import org.yuzu.yuzu_emu.utils.* +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import java.lang.NullPointerException + +class EmulationFragment : Fragment(), SurfaceHolder.Callback { + private lateinit var emulationState: EmulationState + private var emulationActivity: EmulationActivity? = null + private var perfStatsUpdater: (() -> Unit)? = null + private var thermalStatsUpdater: (() -> Unit)? = null + + private var _binding: FragmentEmulationBinding? = null + private val binding get() = _binding!! + + private val args by navArgs() + + private lateinit var game: Game + + private val emulationViewModel: EmulationViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private var isInFoldableLayout = false + + private lateinit var powerManager: PowerManager + + override fun onAttach(context: Context) { + super.onAttach(context) + if (context is EmulationActivity) { + emulationActivity = context + NativeLibrary.setEmulationActivity(context) + } else { + throw IllegalStateException("EmulationFragment must have EmulationActivity parent") + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + updateOrientation() + + powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager + + val intentUri: Uri? = requireActivity().intent.data + var intentGame: Game? = null + if (intentUri != null) { + intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { + GameHelper.getGame(requireActivity().intent.data!!, false) + } else { + null + } + } + + try { + game = if (args.game != null) { + args.game!! + } else { + intentGame!! + } + } catch (e: NullPointerException) { + Toast.makeText( + requireContext(), + R.string.no_game_present, + Toast.LENGTH_SHORT + ).show() + requireActivity().finish() + return + } + + // Always load custom settings when launching a game from an intent + if (args.custom || intentGame != null) { + SettingsFile.loadCustomConfig(game) + NativeConfig.unloadPerGameConfig() + } else { + NativeConfig.reloadGlobalConfig() + } + + // Install the selected driver asynchronously as the game starts + driverViewModel.onLaunchGame() + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + retainInstance = true + emulationState = EmulationState(game.path) { + return@EmulationState driverViewModel.isInteractionAllowed.value + } + } + + /** + * Initialize the UI and start emulation in here. + */ + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentEmulationBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + if (requireActivity().isFinishing) { + return + } + + binding.surfaceEmulation.holder.addCallback(this) + binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } + + binding.drawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { + binding.surfaceInputOverlay.dispatchTouchEvent( + MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis() + 100, + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) + ) + } + + override fun onDrawerOpened(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) + binding.inGameMenu.requestFocus() + emulationViewModel.setDrawerOpen(true) + } + + override fun onDrawerClosed(drawerView: View) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + emulationViewModel.setDrawerOpen(false) + } + + override fun onDrawerStateChanged(newState: Int) { + // No op + } + }) + binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = + game.title + + binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { + val lockMode = IntSetting.LOCK_DRAWER.getInt() + val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { + R.string.unlock_drawer + } else { + R.string.lock_drawer + } + val iconId = if (lockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { + R.drawable.ic_unlock + } else { + R.drawable.ic_lock + } + + title = getString(titleId) + icon = ResourcesCompat.getDrawable( + resources, + iconId, + requireContext().theme + ) + } + + binding.inGameMenu.setNavigationItemSelectedListener { + when (it.itemId) { + R.id.menu_pause_emulation -> { + if (emulationState.isPaused) { + emulationState.run(false) + it.title = resources.getString(R.string.emulation_pause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_pause, + requireContext().theme + ) + } else { + emulationState.pause() + it.title = resources.getString(R.string.emulation_unpause) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_play, + requireContext().theme + ) + } + binding.inGameMenu.requestFocus() + true + } + + R.id.menu_settings -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_settings_per_game -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.inGameMenu.requestFocus() + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_controls -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + true + } + + R.id.menu_overlay_controls -> { + showOverlayOptions() + true + } + + R.id.menu_lock_drawer -> { + when (IntSetting.LOCK_DRAWER.getInt()) { + DrawerLayout.LOCK_MODE_UNLOCKED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + it.title = resources.getString(R.string.unlock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_lock, + requireContext().theme + ) + } + + DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { + IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_UNLOCKED) + it.title = resources.getString(R.string.lock_drawer) + it.icon = ResourcesCompat.getDrawable( + resources, + R.drawable.ic_unlock, + requireContext().theme + ) + } + } + binding.inGameMenu.requestFocus() + NativeConfig.saveGlobalConfig() + true + } + + R.id.menu_exit -> { + emulationState.stop() + NativeConfig.reloadGlobalConfig() + emulationViewModel.setIsEmulationStopping(true) + binding.drawerLayout.close() + binding.inGameMenu.requestFocus() + true + } + + else -> true + } + } + + setInsets() + + requireActivity().onBackPressedDispatcher.addCallback( + requireActivity(), + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (!NativeLibrary.isRunning()) { + return + } + emulationViewModel.setDrawerOpen(!binding.drawerLayout.isOpen) + } + } + ) + + GameIconUtils.loadGameIcon(game, binding.loadingImage) + binding.loadingTitle.text = game.title + binding.loadingTitle.isSelected = true + binding.loadingText.isSelected = true + + WindowInfoTracker.getOrCreate(requireContext()) + .windowLayoutInfo(requireActivity()).collect(viewLifecycleOwner) { + updateFoldableLayout(requireActivity() as EmulationActivity, it) + } + emulationViewModel.shaderProgress.collect(viewLifecycleOwner) { + if (it > 0 && it != emulationViewModel.totalShaders.value) { + binding.loadingProgressIndicator.isIndeterminate = false + + if (it < binding.loadingProgressIndicator.max) { + binding.loadingProgressIndicator.progress = it + } + } + + if (it == emulationViewModel.totalShaders.value) { + binding.loadingText.setText(R.string.loading) + binding.loadingProgressIndicator.isIndeterminate = true + } + } + emulationViewModel.totalShaders.collect(viewLifecycleOwner) { + binding.loadingProgressIndicator.max = it + } + emulationViewModel.shaderMessage.collect(viewLifecycleOwner) { + if (it.isNotEmpty()) { + binding.loadingText.text = it + } + } + + emulationViewModel.emulationStarted.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) + ViewUtils.showView(binding.surfaceInputOverlay) + ViewUtils.hideView(binding.loadingIndicator) + + emulationState.updateSurface() + + // Setup overlays + updateShowFpsOverlay() + updateThermalOverlay() + } + } + emulationViewModel.isEmulationStopping.collect(viewLifecycleOwner) { + if (it) { + binding.loadingText.setText(R.string.shutting_down) + ViewUtils.showView(binding.loadingIndicator) + ViewUtils.hideView(binding.inputContainer) + ViewUtils.hideView(binding.showFpsText) + } + } + emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { + if (it) { + binding.drawerLayout.open() + binding.inGameMenu.requestFocus() + } else { + binding.drawerLayout.close() + } + } + emulationViewModel.programChanged.collect(viewLifecycleOwner) { + if (it != 0) { + emulationViewModel.setEmulationStarted(false) + binding.drawerLayout.close() + binding.drawerLayout + .setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) + ViewUtils.hideView(binding.surfaceInputOverlay) + ViewUtils.showView(binding.loadingIndicator) + } + } + emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { + if (it && emulationViewModel.programChanged.value != -1) { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + emulationState.changeProgram(emulationViewModel.programChanged.value) + emulationViewModel.setProgramChanged(-1) + emulationViewModel.setEmulationStopped(false) + } + } + + driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { + if (it) startEmulation() + } + } + + private fun startEmulation(programIndex: Int = 0) { + if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { + if (!DirectoryInitialization.areDirectoriesReady) { + DirectoryInitialization.start() + } + + updateScreenLayout() + + emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + if (_binding == null) { + return + } + + updateScreenLayout() + val showInputOverlay = BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + if (emulationActivity?.isInPictureInPictureMode == true) { + if (binding.drawerLayout.isOpen) { + binding.drawerLayout.close() + } + if (showInputOverlay) { + binding.surfaceInputOverlay.setVisible(visible = false, gone = false) + } + } else { + binding.surfaceInputOverlay.setVisible( + showInputOverlay && emulationViewModel.emulationStarted.value + ) + if (!isInFoldableLayout) { + if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { + binding.surfaceInputOverlay.layout = OverlayLayout.Portrait + } else { + binding.surfaceInputOverlay.layout = OverlayLayout.Landscape + } + } + } + } + + override fun onPause() { + if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { + emulationState.pause() + } + super.onPause() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onDetach() { + NativeLibrary.clearEmulationActivity() + super.onDetach() + } + + private fun resetInputOverlay() { + IntSetting.OVERLAY_SCALE.reset() + IntSetting.OVERLAY_OPACITY.reset() + binding.surfaceInputOverlay.post { + binding.surfaceInputOverlay.resetLayoutVisibilityAndPlacement() + } + } + + private fun updateShowFpsOverlay() { + val showOverlay = BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + binding.showFpsText.setVisible(showOverlay) + if (showOverlay) { + val SYSTEM_FPS = 0 + val FPS = 1 + val FRAMETIME = 2 + val SPEED = 3 + perfStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val perfStats = NativeLibrary.getPerfStats() + val cpuBackend = NativeLibrary.getCpuBackend() + val gpuDriver = NativeLibrary.getGpuDriver() + if (_binding != null) { + binding.showFpsText.text = + String.format("FPS: %.1f\n%s/%s", perfStats[FPS], cpuBackend, gpuDriver) + } + perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) + } + } + perfStatsUpdateHandler.post(perfStatsUpdater!!) + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) + } + } + } + + private fun updateThermalOverlay() { + val showOverlay = BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + binding.showThermalsText.setVisible(showOverlay) + if (showOverlay) { + thermalStatsUpdater = { + if (emulationViewModel.emulationStarted.value && + !emulationViewModel.isEmulationStopping.value + ) { + val thermalStatus = when (powerManager.currentThermalStatus) { + PowerManager.THERMAL_STATUS_LIGHT -> "😥" + PowerManager.THERMAL_STATUS_MODERATE -> "🥵" + PowerManager.THERMAL_STATUS_SEVERE -> "🔥" + PowerManager.THERMAL_STATUS_CRITICAL, + PowerManager.THERMAL_STATUS_EMERGENCY, + PowerManager.THERMAL_STATUS_SHUTDOWN -> "☢️" + + else -> "🙂" + } + if (_binding != null) { + binding.showThermalsText.text = thermalStatus + } + thermalStatsUpdateHandler.postDelayed(thermalStatsUpdater!!, 1000) + } + } + thermalStatsUpdateHandler.post(thermalStatsUpdater!!) + } else { + if (thermalStatsUpdater != null) { + thermalStatsUpdateHandler.removeCallbacks(thermalStatsUpdater!!) + } + } + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun updateOrientation() { + emulationActivity?.let { + val orientationSetting = + EmulationOrientation.from(IntSetting.RENDERER_SCREEN_LAYOUT.getInt()) + it.requestedOrientation = when (orientationSetting) { + EmulationOrientation.Unspecified -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + EmulationOrientation.SensorLandscape -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + EmulationOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + EmulationOrientation.ReverseLandscape -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE + + EmulationOrientation.SensorPortrait -> + ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT + + EmulationOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + EmulationOrientation.ReversePortrait -> + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT + } + } + } + + private fun updateScreenLayout() { + val verticalAlignment = + EmulationVerticalAlignment.from(IntSetting.VERTICAL_ALIGNMENT.getInt()) + val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { + 0 -> Rational(16, 9) + 1 -> Rational(4, 3) + 2 -> Rational(21, 9) + 3 -> Rational(16, 10) + else -> null // Best fit + } + when (verticalAlignment) { + EmulationVerticalAlignment.Top -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + + EmulationVerticalAlignment.Center -> { + binding.surfaceEmulation.setAspectRatio(null) + binding.surfaceEmulation.updateLayoutParams { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + } + } + + EmulationVerticalAlignment.Bottom -> { + binding.surfaceEmulation.setAspectRatio(aspectRatio) + val params = + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + binding.surfaceEmulation.layoutParams = params + } + } + emulationState.updateSurface() + emulationActivity?.buildPictureInPictureParams() + updateOrientation() + } + + private fun updateFoldableLayout( + emulationActivity: EmulationActivity, + newLayoutInfo: WindowLayoutInfo + ) { + val isFolding = + (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { + if (it.isSeparating) { + emulationActivity.requestedOrientation = + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { + // Restrict emulation and overlays to the top of the screen + binding.emulationContainer.layoutParams.height = it.bounds.top + // Restrict input and menu drawer to the bottom of the screen + binding.inputContainer.layoutParams.height = it.bounds.bottom + binding.inGameMenu.layoutParams.height = it.bounds.bottom + + isInFoldableLayout = true + binding.surfaceInputOverlay.layout = OverlayLayout.Foldable + } + } + it.isSeparating + } ?: false + if (!isFolding) { + binding.emulationContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inputContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT + isInFoldableLayout = false + updateOrientation() + onConfigurationChanged(resources.configuration) + } + binding.emulationContainer.requestLayout() + binding.inputContainer.requestLayout() + binding.inGameMenu.requestLayout() + } + + override fun surfaceCreated(holder: SurfaceHolder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) + emulationState.newSurface(holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + emulationState.clearSurface() + } + + private fun showOverlayOptions() { + val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) + val popup = PopupMenu(requireContext(), anchor) + + popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) + + popup.menu.apply { + findItem(R.id.menu_toggle_fps).isChecked = + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() + findItem(R.id.thermal_indicator).isChecked = + BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() + findItem(R.id.menu_rel_stick_center).isChecked = + BooleanSetting.JOYSTICK_REL_CENTER.getBoolean() + findItem(R.id.menu_dpad_slide).isChecked = BooleanSetting.DPAD_SLIDE.getBoolean() + findItem(R.id.menu_show_overlay).isChecked = + BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() + findItem(R.id.menu_haptics).isChecked = BooleanSetting.HAPTIC_FEEDBACK.getBoolean() + findItem(R.id.menu_touchscreen).isChecked = BooleanSetting.TOUCHSCREEN.getBoolean() + } + + popup.setOnDismissListener { NativeConfig.saveGlobalConfig() } + popup.setOnMenuItemClickListener { + when (it.itemId) { + R.id.menu_toggle_fps -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(it.isChecked) + updateShowFpsOverlay() + true + } + + R.id.thermal_indicator -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_THERMAL_OVERLAY.setBoolean(it.isChecked) + updateThermalOverlay() + true + } + + R.id.menu_edit_overlay -> { + binding.drawerLayout.close() + binding.surfaceInputOverlay.requestFocus() + startConfiguringControls() + true + } + + R.id.menu_adjust_overlay -> { + adjustOverlay() + true + } + + R.id.menu_toggle_controls -> { + val overlayControlData = NativeConfig.getOverlayControlData() + val optionsArray = BooleanArray(overlayControlData.size) + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = overlayControlData.firstOrNull { data -> + OverlayControl.entries[i].id == data.id + }?.enabled == true + } + + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_toggle_controls) + .setMultiChoiceItems( + R.array.gamepadButtons, + optionsArray + ) { _, indexSelected, isChecked -> + overlayControlData.firstOrNull { data -> + OverlayControl.entries[indexSelected].id == data.id + }?.enabled = isChecked + } + .setPositiveButton(android.R.string.ok) { _, _ -> + NativeConfig.setOverlayControlData(overlayControlData) + NativeConfig.saveGlobalConfig() + binding.surfaceInputOverlay.refreshControls() + } + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } + .show() + + // Override normal behaviour so the dialog doesn't close + dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + .setOnClickListener { + val isChecked = !optionsArray[0] + overlayControlData.forEachIndexed { i, _ -> + optionsArray[i] = isChecked + dialog.listView.setItemChecked(i, isChecked) + overlayControlData[i].enabled = isChecked + } + } + true + } + + R.id.menu_show_overlay -> { + it.isChecked = !it.isChecked + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(it.isChecked) + binding.surfaceInputOverlay.refreshControls() + true + } + + R.id.menu_rel_stick_center -> { + it.isChecked = !it.isChecked + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(it.isChecked) + true + } + + R.id.menu_dpad_slide -> { + it.isChecked = !it.isChecked + BooleanSetting.DPAD_SLIDE.setBoolean(it.isChecked) + true + } + + R.id.menu_haptics -> { + it.isChecked = !it.isChecked + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(it.isChecked) + true + } + + R.id.menu_touchscreen -> { + it.isChecked = !it.isChecked + BooleanSetting.TOUCHSCREEN.setBoolean(it.isChecked) + true + } + + R.id.menu_reset_overlay -> { + binding.drawerLayout.close() + resetInputOverlay() + true + } + + else -> true + } + } + + popup.show() + } + + @SuppressLint("SourceLockedOrientationActivity") + private fun startConfiguringControls() { + // Lock the current orientation to prevent editing inconsistencies + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE + } + } + } + binding.doneControlConfig.setVisible(true) + binding.surfaceInputOverlay.setIsInEditMode(true) + } + + private fun stopConfiguringControls() { + binding.doneControlConfig.setVisible(false) + binding.surfaceInputOverlay.setIsInEditMode(false) + // Unlock the orientation if it was locked for editing + if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { + emulationActivity?.let { + it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + NativeConfig.saveGlobalConfig() + } + + @SuppressLint("SetTextI18n") + private fun adjustOverlay() { + val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) + adjustBinding.apply { + inputScaleSlider.apply { + valueTo = 150F + value = IntSetting.OVERLAY_SCALE.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputScaleValue.text = "${value.toInt()}%" + setControlScale(value.toInt()) + } + ) + } + inputOpacitySlider.apply { + valueTo = 100F + value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() + addOnChangeListener( + Slider.OnChangeListener { _, value, _ -> + inputOpacityValue.text = "${value.toInt()}%" + setControlOpacity(value.toInt()) + } + ) + } + inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" + inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" + } + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.emulation_control_adjust) + .setView(adjustBinding.root) + .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + NativeConfig.saveGlobalConfig() + } + .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> + setControlScale(50) + setControlOpacity(100) + } + .show() + } + + private fun setControlScale(scale: Int) { + IntSetting.OVERLAY_SCALE.setInt(scale) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setControlOpacity(opacity: Int) { + IntSetting.OVERLAY_OPACITY.setInt(opacity) + binding.surfaceInputOverlay.refreshControls() + } + + private fun setInsets() { + ViewCompat.setOnApplyWindowInsetsListener( + binding.inGameMenu + ) { v: View, windowInsets: WindowInsetsCompat -> + val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + var left = 0 + var right = 0 + if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = cutInsets.left + } else { + right = cutInsets.right + } + + v.updatePadding(left = left, top = cutInsets.top, right = right) + windowInsets + } + } + + private class EmulationState( + private val gamePath: String, + private val emulationCanStart: () -> Boolean + ) { + private var state: State + private var surface: Surface? = null + lateinit var emulationThread: Thread + + init { + // Starting state is stopped. + state = State.STOPPED + } + + @get:Synchronized + val isStopped: Boolean + get() = state == State.STOPPED + + // Getters for the current state + @get:Synchronized + val isPaused: Boolean + get() = state == State.PAUSED + + @get:Synchronized + val isRunning: Boolean + get() = state == State.RUNNING + + @Synchronized + fun stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation.") + NativeLibrary.stopEmulation() + state = State.STOPPED + } else { + Log.warning("[EmulationFragment] Stop called while already stopped.") + } + } + + // State changing methods + @Synchronized + fun pause() { + if (state != State.PAUSED) { + Log.debug("[EmulationFragment] Pausing emulation.") + + NativeLibrary.pauseEmulation() + + state = State.PAUSED + } else { + Log.warning("[EmulationFragment] Pause called while already paused.") + } + } + + @Synchronized + fun run(isActivityRecreated: Boolean, programIndex: Int = 0) { + if (isActivityRecreated) { + if (NativeLibrary.isRunning()) { + state = State.PAUSED + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start") + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (surface != null) { + runWithValidSurface(programIndex) + } + } + + @Synchronized + fun changeProgram(programIndex: Int) { + emulationThread.join() + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, false) + }, "NativeEmulation") + emulationThread.start() + } + + // Surface callbacks + @Synchronized + fun newSurface(surface: Surface?) { + this.surface = surface + if (this.surface != null) { + runWithValidSurface() + } + } + + @Synchronized + fun updateSurface() { + if (surface != null) { + NativeLibrary.surfaceChanged(surface) + } + } + + @Synchronized + fun clearSurface() { + if (surface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null.") + } else { + surface = null + Log.debug("[EmulationFragment] Surface destroyed.") + when (state) { + State.RUNNING -> { + state = State.PAUSED + } + + State.PAUSED -> Log.warning( + "[EmulationFragment] Surface cleared while emulation paused." + ) + + else -> Log.warning( + "[EmulationFragment] Surface cleared while emulation stopped." + ) + } + } + } + + private fun runWithValidSurface(programIndex: Int = 0) { + NativeLibrary.surfaceChanged(surface) + if (!emulationCanStart.invoke()) { + return + } + + when (state) { + State.STOPPED -> { + emulationThread = Thread({ + Log.debug("[EmulationFragment] Starting emulation thread.") + NativeLibrary.run(gamePath, programIndex, true) + }, "NativeEmulation") + emulationThread.start() + } + + State.PAUSED -> { + Log.debug("[EmulationFragment] Resuming emulation.") + NativeLibrary.unpauseEmulation() + } + + else -> Log.debug("[EmulationFragment] Bug, run called while already running.") + } + state = State.RUNNING + } + + private enum class State { + STOPPED, RUNNING, PAUSED + } + } + + companion object { + private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) + private val thermalStatsUpdateHandler = Handler(Looper.myLooper()!!) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt new file mode 100644 index 000000000..1ea1e036e --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class GameFolderPropertiesDialogFragment : DialogFragment() { + private val gamesViewModel: GamesViewModel by activityViewModels() + + private var deepScan = false + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) + val gameDir = requireArguments().parcelable(GAME_DIR)!! + + // Restore checkbox state + binding.deepScanSwitch.isChecked = + savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan + + // Ensure that we can get the checkbox state even if the view is destroyed + deepScan = binding.deepScanSwitch.isChecked + binding.deepScanSwitch.setOnClickListener { + deepScan = binding.deepScanSwitch.isChecked + } + + return MaterialAlertDialogBuilder(requireContext()) + .setView(binding.root) + .setTitle(R.string.game_folder_properties) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) + if (folderIndex != -1) { + gamesViewModel.folders.value[folderIndex].deepScan = + binding.deepScanSwitch.isChecked + gamesViewModel.updateGameDirs() + } + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onStop() { + super.onStop() + NativeConfig.saveGlobalConfig() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(DEEP_SCAN, deepScan) + } + + companion object { + const val TAG = "GameFolderPropertiesDialogFragment" + + private const val GAME_DIR = "GameDir" + + private const val DEEP_SCAN = "DeepScan" + + fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment { + val args = Bundle() + args.putParcelable(GAME_DIR, gameDir) + val fragment = GameFolderPropertiesDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFoldersFragment.kt new file mode 100644 index 000000000..3a6f7a38c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameFoldersFragment.kt @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.FolderAdapter +import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect + +class GameFoldersFragment : Fragment() { + private var _binding: FragmentFoldersBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel 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) + + gamesViewModel.onOpenGameFoldersFragment() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentFoldersBinding.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) + + binding.toolbarFolders.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + binding.listFolders.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = FolderAdapter(requireActivity(), gamesViewModel) + } + + gamesViewModel.folders.collect(viewLifecycleOwner) { + (binding.listFolders.adapter as FolderAdapter).submitList(it) + } + + val mainActivity = requireActivity() as MainActivity + binding.buttonAdd.setOnClickListener { + mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) + } + + setInsets() + } + + override fun onStop() { + super.onStop() + gamesViewModel.onCloseGameFoldersFragment() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarFolders.updateMargins(left = leftInsets, right = rightInsets) + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonAdd.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.listFolders.updateMargins(left = leftInsets, right = rightInsets) + + binding.listFolders.updatePadding( + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameInfoFragment.kt new file mode 100644 index 000000000..97a8954bb --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GameInfoFragment.kt @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding +import org.yuzu.yuzu_emu.model.GameVerificationResult +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.GameMetadata +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +class GameInfoFragment : Fragment() { + private var _binding: FragmentGameInfoBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + + // Check for an up-to-date version string + args.game.version = GameMetadata.getVersion(args.game.path, true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGameInfoBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = false) + homeViewModel.setStatusBarShadeVisibility(false) + + binding.apply { + toolbarInfo.title = args.game.title + toolbarInfo.setNavigationOnClickListener { + view.findNavController().popBackStack() + } + + val pathString = Uri.parse(args.game.path).path ?: "" + path.setHint(R.string.path) + pathField.setText(pathString) + pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } + + programId.setHint(R.string.program_id) + programIdField.setText(args.game.programIdHex) + programIdField.setOnClickListener { + copyToClipboard(getString(R.string.program_id), args.game.programIdHex) + } + + if (args.game.developer.isNotEmpty()) { + developer.setHint(R.string.developer) + developerField.setText(args.game.developer) + developerField.setOnClickListener { + copyToClipboard(getString(R.string.developer), args.game.developer) + } + } else { + developer.setVisible(false) + } + + version.setHint(R.string.version) + versionField.setText(args.game.version) + versionField.setOnClickListener { + copyToClipboard(getString(R.string.version), args.game.version) + } + + buttonCopy.setOnClickListener { + val details = """ + ${args.game.title} + ${getString(R.string.path)} - $pathString + ${getString(R.string.program_id)} - ${args.game.programIdHex} + ${getString(R.string.developer)} - ${args.game.developer} + ${getString(R.string.version)} - ${args.game.version} + """.trimIndent() + copyToClipboard(args.game.title, details) + } + + buttonVerifyIntegrity.setOnClickListener { + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.verifying, + true + ) { progressCallback, _ -> + val result = GameVerificationResult.from( + NativeLibrary.verifyGameContents( + args.game.path, + progressCallback + ) + ) + return@newInstance when (result) { + GameVerificationResult.Success -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_success, + descriptionId = R.string.operation_completed_successfully + ) + + GameVerificationResult.Failed -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_failure, + descriptionId = R.string.verify_failure_description + ) + + GameVerificationResult.NotImplemented -> + MessageDialogFragment.newInstance( + titleId = R.string.verify_no_result, + descriptionId = R.string.verify_no_result_description + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + } + + setInsets() + } + + private fun copyToClipboard(label: String, body: String) { + val clipBoard = + requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText(label, body) + clipBoard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + Toast.LENGTH_SHORT + ).show() + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarInfo.updateMargins(left = leftInsets, right = rightInsets) + binding.scrollInfo.updateMargins(left = leftInsets, right = rightInsets) + + binding.contentInfo.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GamePropertiesFragment.kt new file mode 100644 index 000000000..c06842c59 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -0,0 +1,424 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.pm.ShortcutInfo +import android.content.pm.ShortcutManager +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +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.lifecycleScope +import androidx.navigation.findNavController +import androidx.navigation.fragment.navArgs +import androidx.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter +import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.GameProperty +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.InstallableProperty +import org.yuzu.yuzu_emu.model.SubmenuProperty +import org.yuzu.yuzu_emu.model.TaskState +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GameIconUtils +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.MemoryUtil +import org.yuzu.yuzu_emu.utils.ViewUtils.marquee +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect +import java.io.BufferedOutputStream +import java.io.File + +class GamePropertiesFragment : Fragment() { + private var _binding: FragmentGamePropertiesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + private val gamesViewModel: GamesViewModel by activityViewModels() + private val driverViewModel: DriverViewModel by activityViewModels() + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false) + reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamePropertiesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + + binding.buttonBack.setOnClickListener { + view.findNavController().popBackStack() + } + + val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java) + binding.buttonShortcut.isEnabled = shortcutManager.isRequestPinShortcutSupported + binding.buttonShortcut.setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + withContext(Dispatchers.IO) { + val shortcut = ShortcutInfo.Builder(requireContext(), args.game.title) + .setShortLabel(args.game.title) + .setIcon( + GameIconUtils.getShortcutIcon(requireActivity(), args.game) + .toIcon(requireContext()) + ) + .setIntent(args.game.launchIntent) + .build() + shortcutManager.requestPinShortcut(shortcut, null) + } + } + } + + GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen) + binding.title.text = args.game.title + binding.title.marquee() + + binding.buttonStart.setOnClickListener { + LaunchGameDialogFragment.newInstance(args.game) + .show(childFragmentManager, LaunchGameDialogFragment.TAG) + } + + reloadList() + + homeViewModel.openImportSaves.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setOpenImportSaves(false) } + ) { if (it) importSaves.launch(arrayOf("application/zip")) } + homeViewModel.reloadPropertiesList.collect( + viewLifecycleOwner, + resetState = { homeViewModel.reloadPropertiesList(false) } + ) { if (it) reloadList() } + + setInsets() + } + + override fun onDestroy() { + super.onDestroy() + gamesViewModel.reloadGames(true) + } + + private fun reloadList() { + _binding ?: return + + driverViewModel.updateDriverNameForGame(args.game) + val properties = mutableListOf().apply { + add( + SubmenuProperty( + R.string.info, + R.string.info_description, + R.drawable.ic_info_outline + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + SubmenuProperty( + R.string.preferences_settings, + R.string.per_game_settings_description, + R.drawable.ic_settings + ) { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + args.game, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + } + ) + + if (GpuDriverHelper.supportsCustomDriverLoading()) { + add( + SubmenuProperty( + R.string.gpu_driver_manager, + R.string.install_gpu_driver_description, + R.drawable.ic_build, + detailsFlow = driverViewModel.selectedDriverTitle + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + } + + if (!args.game.isHomebrew) { + add( + SubmenuProperty( + R.string.add_ons, + R.string.add_ons_description, + R.drawable.ic_edit + ) { + val action = GamePropertiesFragmentDirections + .actionPerGamePropertiesFragmentToAddonsFragment(args.game) + binding.root.findNavController().navigate(action) + } + ) + add( + InstallableProperty( + R.string.save_data, + R.string.save_data_description, + R.drawable.ic_save, + { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_save_warning, + descriptionId = R.string.import_save_warning_description, + positiveAction = { homeViewModel.setOpenImportSaves(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + }, + if (File(args.game.saveDir).exists()) { + { exportSaves.launch(args.game.saveZipName) } + } else { + null + } + ) + ) + + val saveDirFile = File(args.game.saveDir) + if (saveDirFile.exists()) { + add( + SubmenuProperty( + R.string.delete_save_data, + R.string.delete_save_data_description, + R.drawable.ic_delete, + action = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.delete_save_data, + descriptionId = R.string.delete_save_data_warning_description, + positiveButtonTitleId = android.R.string.cancel, + negativeButtonTitleId = android.R.string.ok, + negativeAction = { + File(args.game.saveDir).deleteRecursively() + Toast.makeText( + YuzuApplication.appContext, + R.string.save_data_deleted_successfully, + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + ) + ) + } + + val shaderCacheDir = File( + DirectoryInitialization.userDirectory + + "/shader/" + args.game.settingsName.lowercase() + ) + if (shaderCacheDir.exists()) { + add( + SubmenuProperty( + R.string.clear_shader_cache, + R.string.clear_shader_cache_description, + R.drawable.ic_delete, + { + if (shaderCacheDir.exists()) { + val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } + .map { it.length() }.sum() + MemoryUtil.bytesToSizeUnit(bytes.toFloat()) + } else { + MemoryUtil.bytesToSizeUnit(0f) + } + } + ) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.clear_shader_cache, + descriptionId = R.string.clear_shader_cache_warning_description, + positiveAction = { + shaderCacheDir.deleteRecursively() + Toast.makeText( + YuzuApplication.appContext, + R.string.cleared_shaders_successfully, + Toast.LENGTH_SHORT + ).show() + homeViewModel.reloadPropertiesList(true) + } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + ) + } + } + } + binding.listProperties.apply { + layoutManager = + GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) + adapter = GamePropertiesAdapter(viewLifecycleOwner, properties) + } + } + + override fun onResume() { + super.onResume() + driverViewModel.updateDriverNameForGame(args.game) + } + + 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 smallLayout = resources.getBoolean(R.bool.small_layout) + if (smallLayout) { + binding.listAll.updateMargins(left = leftInsets, right = rightInsets) + } else { + if (ViewCompat.getLayoutDirection(binding.root) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + binding.listAll.updateMargins(right = rightInsets) + binding.iconLayout!!.updateMargins(top = barInsets.top, left = leftInsets) + } else { + binding.listAll.updateMargins(left = leftInsets) + binding.iconLayout!!.updateMargins(top = barInsets.top, right = rightInsets) + } + } + + val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) + binding.buttonStart.updateMargins( + left = leftInsets + fabSpacing, + right = rightInsets + fabSpacing, + bottom = barInsets.bottom + fabSpacing + ) + + binding.layoutAll.updatePadding( + top = barInsets.top, + bottom = barInsets.bottom + + resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) + ) + + windowInsets + } + + private val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val savesFolder = File(args.game.saveDir) + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_importing, + false + ) { _, _ -> + try { + FileUtil.unzipToInternalStorage(result.toString(), cacheSaveDir) + val files = cacheSaveDir.listFiles() + var savesFolderFile: File? = null + if (files != null) { + val savesFolderName = args.game.programIdHex + for (file in files) { + if (file.isDirectory && file.name == savesFolderName) { + savesFolderFile = file + break + } + } + } + + if (savesFolderFile != null) { + savesFolder.deleteRecursively() + savesFolder.mkdir() + savesFolderFile.copyRecursively(savesFolder) + savesFolderFile.deleteRecursively() + } + + withContext(Dispatchers.Main) { + if (savesFolderFile == null) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.save_file_invalid_zip_structure, + descriptionId = R.string.save_file_invalid_zip_structure_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + Toast.makeText( + YuzuApplication.appContext, + getString(R.string.save_file_imported_success), + Toast.LENGTH_LONG + ).show() + homeViewModel.reloadPropertiesList(true) + } + + cacheSaveDir.deleteRecursively() + } catch (e: Exception) { + Toast.makeText( + YuzuApplication.appContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + /** + * Exports the save file located in the given folder path by creating a zip file and opening a + * file picker to save. + */ + private val exportSaves = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { _, _ -> + val saveLocation = args.game.saveDir + val zipResult = FileUtil.zipFromInternalStorage( + File(saveLocation), + saveLocation.replaceAfterLast("/", ""), + BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)), + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/HomeSettingsFragment.kt new file mode 100644 index 000000000..14a2504b6 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/HomeSettingsFragment.kt @@ -0,0 +1,437 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.DocumentsContract +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.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.recyclerview.widget.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.BuildConfig +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter +import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding +import org.yuzu.yuzu_emu.features.DocumentProvider +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.HomeSetting +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.Log +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +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() + + 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?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = true) + mainActivity = requireActivity() as MainActivity + + val optionsList: MutableList = mutableListOf().apply { + add( + HomeSetting( + R.string.advanced_settings, + R.string.settings_description, + R.drawable.ic_settings, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.preferences_controls, + R.string.preferences_controls_description, + R.drawable.ic_controller, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_INPUT + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.gpu_driver_manager, + R.string.install_gpu_driver_description, + R.drawable.ic_build, + { + val action = HomeSettingsFragmentDirections + .actionHomeSettingsFragmentToDriverManagerFragment(null) + binding.root.findNavController().navigate(action) + }, + { GpuDriverHelper.supportsCustomDriverLoading() }, + R.string.custom_driver_not_supported, + R.string.custom_driver_not_supported_description, + driverViewModel.selectedDriverTitle + ) + ) + add( + HomeSetting( + R.string.applets, + R.string.applets_description, + R.drawable.ic_applet, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_appletLauncherFragment) + }, + { NativeLibrary.isFirmwareAvailable() }, + R.string.applets_error_firmware, + R.string.applets_error_description + ) + ) + add( + HomeSetting( + R.string.manage_yuzu_data, + R.string.manage_yuzu_data_description, + R.drawable.ic_install, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_installableFragment) + } + ) + ) + add( + HomeSetting( + R.string.manage_game_folders, + R.string.select_games_folder_description, + R.drawable.ic_add, + { + binding.root.findNavController() + .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment) + } + ) + ) + add( + HomeSetting( + R.string.verify_installed_content, + R.string.verify_installed_content_description, + R.drawable.ic_check_circle, + { + ProgressDialogFragment.newInstance( + requireActivity(), + titleId = R.string.verifying, + cancellable = true + ) { progressCallback, _ -> + val result = NativeLibrary.verifyInstalledContents(progressCallback) + return@newInstance if (progressCallback.invoke(100, 100)) { + // Invoke the progress callback to check if the process was cancelled + MessageDialogFragment.newInstance( + titleId = R.string.verify_no_result, + descriptionId = R.string.verify_no_result_description + ) + } else if (result.isEmpty()) { + MessageDialogFragment.newInstance( + titleId = R.string.verify_success, + descriptionId = R.string.operation_completed_successfully + ) + } else { + val failedNames = result.joinToString("\n") + val errorMessage = YuzuApplication.appContext.getString( + R.string.verification_failed_for, + failedNames + ) + MessageDialogFragment.newInstance( + titleId = R.string.verify_failure, + descriptionString = errorMessage + ) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + ) + ) + add( + HomeSetting( + R.string.share_log, + R.string.share_log_description, + R.drawable.ic_log, + { shareLog() } + ) + ) + add( + HomeSetting( + R.string.open_user_folder, + R.string.open_user_folder_description, + R.drawable.ic_folder_open, + { openFileManager() } + ) + ) + add( + HomeSetting( + R.string.preferences_theme, + R.string.theme_and_color_description, + R.drawable.ic_palette, + { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_THEME + ) + binding.root.findNavController().navigate(action) + } + ) + ) + add( + HomeSetting( + R.string.about, + R.string.about_description, + R.drawable.ic_info_outline, + { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) + } + ) + ) + } + + if (!BuildConfig.PREMIUM) { + optionsList.add( + 0, + HomeSetting( + R.string.get_early_access, + R.string.get_early_access_description, + R.drawable.ic_diamond, + { + exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + parentFragmentManager.primaryNavigationFragment?.findNavController() + ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) + } + ) + ) + } + + binding.homeSettingsList.apply { + layoutManager = + GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) + adapter = HomeSettingAdapter( + requireActivity() as AppCompatActivity, + viewLifecycleOwner, + optionsList + ) + } + + setInsets() + } + + override fun onStart() { + super.onStart() + exitTransition = null + } + + override fun onResume() { + super.onResume() + driverViewModel.updateDriverNameForGame(null) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun openFileManager() { + // First, try to open the user data folder directly + try { + startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) + return + } catch (_: ActivityNotFoundException) { + } + + try { + startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) + return + } catch (_: ActivityNotFoundException) { + } + + // Just try to open the file manager, try the package name used on "normal" phones + try { + startActivity(getFileManagerIntent("com.google.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + try { + // Next, try the AOSP package name + startActivity(getFileManagerIntent("com.android.documentsui")) + showNoLinkNotification() + return + } catch (_: ActivityNotFoundException) { + } + + Toast.makeText( + requireContext(), + resources.getString(R.string.no_file_manager), + Toast.LENGTH_LONG + ).show() + } + + private fun getFileManagerIntent(packageName: String): Intent { + // Fragile, but some phones don't expose the system file manager in any better way + val intent = Intent(Intent.ACTION_MAIN) + intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + return intent + } + + private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { + val authority = "${requireContext().packageName}.user" + val intent = Intent(action) + intent.addCategory(Intent.CATEGORY_DEFAULT) + intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) + intent.addFlags( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + return intent + } + + private fun showNoLinkNotification() { + val builder = NotificationCompat.Builder( + requireContext(), + getString(R.string.notice_notification_channel_id) + ) + .setSmallIcon(R.drawable.ic_stat_notification_logo) + .setContentTitle(getString(R.string.notification_no_directory_link)) + .setContentText(getString(R.string.notification_no_directory_link_description)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + // TODO: Make the click action for this notification lead to a help article + + with(NotificationManagerCompat.from(requireContext())) { + if (ActivityCompat.checkSelfPermission( + requireContext(), + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Toast.makeText( + requireContext(), + resources.getString(R.string.notification_permission_not_granted), + Toast.LENGTH_LONG + ).show() + return + } + notify(0, builder.build()) + } + } + + // Share the current log if we just returned from a game but share the old log + // if we just started the app and the old log exists. + private fun shareLog() { + val currentLog = DocumentFile.fromSingleUri( + mainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt" + ) + )!! + val oldLog = DocumentFile.fromSingleUri( + mainActivity, + DocumentsContract.buildDocumentUri( + DocumentProvider.AUTHORITY, + "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt.old.txt" + ) + )!! + + val intent = Intent(Intent.ACTION_SEND) + .setDataAndType(currentLog.uri, FileUtil.TEXT_PLAIN) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (!Log.gameLaunched && oldLog.exists()) { + intent.putExtra(Intent.EXTRA_STREAM, oldLog.uri) + startActivity(Intent.createChooser(intent, getText(R.string.share_log))) + } else if (currentLog.exists()) { + 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_missing), + 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 + ) + + binding.scrollViewSettings.updateMargins(left = leftInsets, right = rightInsets) + + 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/citron/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/InstallableFragment.kt new file mode 100644 index 000000000..d218da1c8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/InstallableFragment.kt @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +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.GridLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.InstallableAdapter +import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.Installable +import org.yuzu.yuzu_emu.model.TaskState +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.FileUtil +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect +import java.io.BufferedOutputStream +import java.io.File +import java.math.BigInteger +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class InstallableFragment : Fragment() { + private var _binding: FragmentInstallablesBinding? = 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 = FragmentInstallablesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarInstallables.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + homeViewModel.openImportSaves.collect(viewLifecycleOwner) { + if (it) { + importSaves.launch(arrayOf("application/zip")) + homeViewModel.setOpenImportSaves(false) + } + } + + val installables = listOf( + Installable( + R.string.user_data, + R.string.user_data_description, + install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, + export = { mainActivity.exportUserData.launch("export.zip") } + ), + Installable( + R.string.manage_save_data, + R.string.manage_save_data_description, + install = { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_save_warning, + descriptionId = R.string.import_save_warning_description, + positiveAction = { homeViewModel.setOpenImportSaves(true) } + ).show(parentFragmentManager, MessageDialogFragment.TAG) + }, + export = { + val oldSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(false) + ) + val futureSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(true) + ) + if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) { + Toast.makeText( + YuzuApplication.appContext, + R.string.no_save_data_found, + Toast.LENGTH_SHORT + ).show() + return@Installable + } else { + exportSaves.launch( + "${getString(R.string.save_data)} " + + LocalDateTime.now().format( + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + ) + ) + } + } + ), + Installable( + R.string.install_game_content, + R.string.install_game_content_description, + install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_firmware, + R.string.install_firmware_description, + install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) } + ), + Installable( + R.string.install_prod_keys, + R.string.install_prod_keys_description, + install = { mainActivity.getProdKey.launch(arrayOf("*/*")) } + ), + Installable( + R.string.install_amiibo_keys, + R.string.install_amiibo_keys_description, + install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } + ) + ) + + binding.listInstallables.apply { + layoutManager = GridLayoutManager( + requireContext(), + resources.getInteger(R.integer.grid_columns) + ) + adapter = InstallableAdapter(installables) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.toolbarInstallables.updateMargins(left = leftInsets, right = rightInsets) + binding.listInstallables.updateMargins(left = leftInsets, right = rightInsets) + + binding.listInstallables.updatePadding(bottom = barInsets.bottom) + + windowInsets + } + + private val importSaves = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_importing, + false + ) { progressCallback, _ -> + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheSaveDir, + progressCallback + ) + val files = cacheSaveDir.listFiles() + var successfulImports = 0 + var failedImports = 0 + if (files != null) { + for (file in files) { + if (file.isDirectory) { + val baseSaveDir = + NativeLibrary.getSavePath(BigInteger(file.name, 16).toString()) + if (baseSaveDir.isEmpty()) { + failedImports++ + continue + } + + val internalSaveFolder = File( + "${DirectoryInitialization.userDirectory}/nand$baseSaveDir" + ) + internalSaveFolder.deleteRecursively() + internalSaveFolder.mkdir() + file.copyRecursively(target = internalSaveFolder, overwrite = true) + successfulImports++ + } + } + } + + withContext(Dispatchers.Main) { + if (successfulImports == 0) { + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.save_file_invalid_zip_structure, + descriptionId = R.string.save_file_invalid_zip_structure_description + ).show(parentFragmentManager, MessageDialogFragment.TAG) + return@withContext + } + val successString = if (failedImports > 0) { + """ + ${ + requireContext().resources.getQuantityString( + R.plurals.saves_import_success, + successfulImports, + successfulImports + ) + } + ${ + requireContext().resources.getQuantityString( + R.plurals.saves_import_failed, + failedImports, + failedImports + ) + } + """ + } else { + requireContext().resources.getQuantityString( + R.plurals.saves_import_success, + successfulImports, + successfulImports + ) + } + MessageDialogFragment.newInstance( + requireActivity(), + titleId = R.string.import_complete, + descriptionString = successString + ).show(parentFragmentManager, MessageDialogFragment.TAG) + } + + cacheSaveDir.deleteRecursively() + } catch (e: Exception) { + Toast.makeText( + YuzuApplication.appContext, + getString(R.string.fatal_error), + Toast.LENGTH_LONG + ).show() + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } + + private val exportSaves = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + requireActivity(), + R.string.save_files_exporting, + false + ) { _, _ -> + val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") + cacheSaveDir.mkdir() + + val oldSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(false) + ) + if (oldSaveDataFolder.exists()) { + oldSaveDataFolder.copyRecursively(cacheSaveDir) + } + + val futureSaveDataFolder = File( + "${DirectoryInitialization.userDirectory}/nand" + + NativeLibrary.getDefaultProfileSaveDataRoot(true) + ) + if (futureSaveDataFolder.exists()) { + futureSaveDataFolder.copyRecursively(cacheSaveDir) + } + + val saveFilesTotal = cacheSaveDir.listFiles()?.size ?: 0 + if (saveFilesTotal == 0) { + cacheSaveDir.deleteRecursively() + return@newInstance getString(R.string.no_save_data_found) + } + + val zipResult = FileUtil.zipFromInternalStorage( + cacheSaveDir, + cacheSaveDir.path, + BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)) + ) + cacheSaveDir.deleteRecursively() + + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.export_success) + TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) + } + }.show(parentFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LaunchGameDialogFragment.kt new file mode 100644 index 000000000..e1ac46c48 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LaunchGameDialogFragment.kt @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class LaunchGameDialogFragment : DialogFragment() { + private var selectedItem = 1 + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val game = requireArguments().parcelable(GAME) + val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom)) + + if (savedInstanceState != null) { + selectedItem = savedInstanceState.getInt(SELECTED_ITEM) + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.launch_options) + .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> + val action = HomeNavigationDirections + .actionGlobalEmulationActivity(game, selectedItem != 0) + requireParentFragment().findNavController().navigate(action) + } + .setSingleChoiceItems(launchOptions, 1) { _: DialogInterface, i: Int -> + selectedItem = i + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putInt(SELECTED_ITEM, selectedItem) + } + + companion object { + const val TAG = "LaunchGameDialogFragment" + + const val GAME = "Game" + const val SELECTED_ITEM = "SelectedItem" + + fun newInstance(game: Game): LaunchGameDialogFragment { + val args = Bundle() + args.putParcelable(GAME, game) + val fragment = LaunchGameDialogFragment() + fragment.arguments = args + return fragment + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt new file mode 100644 index 000000000..78419191c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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.yuzu.yuzu_emu.databinding.DialogLicenseBinding +import org.yuzu.yuzu_emu.model.License +import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable + +class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { + private var _binding: DialogLicenseBinding? = null + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogLicenseBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + BottomSheetBehavior.from(view.parent as View).state = + BottomSheetBehavior.STATE_HALF_EXPANDED + + val license = requireArguments().parcelable(LICENSE)!! + + binding.apply { + textTitle.setText(license.titleId) + textLink.setText(license.linkId) + textCopyright.setText(license.copyrightId) + textLicense.setText(license.licenseId) + } + } + + companion object { + const val TAG = "LicenseBottomSheetDialogFragment" + + const val LICENSE = "License" + + fun newInstance( + license: License + ): LicenseBottomSheetDialogFragment { + val dialog = LicenseBottomSheetDialogFragment() + val bundle = Bundle() + bundle.putParcelable(LICENSE, license) + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicensesFragment.kt new file mode 100644 index 000000000..f17f621f8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/LicensesFragment.kt @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.navigation.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.transition.MaterialSharedAxis +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.LicenseAdapter +import org.yuzu.yuzu_emu.databinding.FragmentLicensesBinding +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.License +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins + +class LicensesFragment : Fragment() { + private var _binding: FragmentLicensesBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) + returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentLicensesBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + homeViewModel.setNavigationVisibility(visible = false, animated = true) + homeViewModel.setStatusBarShadeVisibility(visible = false) + + binding.toolbarLicenses.setNavigationOnClickListener { + binding.root.findNavController().popBackStack() + } + + val licenses = listOf( + License( + R.string.license_fidelityfx_fsr, + R.string.license_fidelityfx_fsr_description, + R.string.license_fidelityfx_fsr_link, + R.string.license_fidelityfx_fsr_copyright, + R.string.license_fidelityfx_fsr_text + ), + License( + R.string.license_cubeb, + R.string.license_cubeb_description, + R.string.license_cubeb_link, + R.string.license_cubeb_copyright, + R.string.license_cubeb_text + ), + License( + R.string.license_dynarmic, + R.string.license_dynarmic_description, + R.string.license_dynarmic_link, + R.string.license_dynarmic_copyright, + R.string.license_dynarmic_text + ), + License( + R.string.license_ffmpeg, + R.string.license_ffmpeg_description, + R.string.license_ffmpeg_link, + R.string.license_ffmpeg_copyright, + R.string.license_ffmpeg_text + ), + License( + R.string.license_opus, + R.string.license_opus_description, + R.string.license_opus_link, + R.string.license_opus_copyright, + R.string.license_opus_text + ), + License( + R.string.license_sirit, + R.string.license_sirit_description, + R.string.license_sirit_link, + R.string.license_sirit_copyright, + R.string.license_sirit_text + ), + License( + R.string.license_adreno_tools, + R.string.license_adreno_tools_description, + R.string.license_adreno_tools_link, + R.string.license_adreno_tools_copyright, + R.string.license_adreno_tools_text + ) + ) + + binding.listLicenses.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses) + } + + setInsets() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + + binding.appbarLicenses.updateMargins(left = leftInsets, right = rightInsets) + binding.listLicenses.updateMargins(left = leftInsets, right = rightInsets) + + binding.listLicenses.updatePadding(bottom = barInsets.bottom) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/MessageDialogFragment.kt new file mode 100644 index 000000000..c370964e1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/MessageDialogFragment.kt @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.Html +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.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.model.MessageDialogViewModel +import org.yuzu.yuzu_emu.utils.Log + +class MessageDialogFragment : DialogFragment() { + private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val titleId = requireArguments().getInt(TITLE_ID) + val title = if (titleId != 0) { + getString(titleId) + } else { + requireArguments().getString(TITLE_STRING)!! + } + + val descriptionId = requireArguments().getInt(DESCRIPTION_ID) + val description = if (descriptionId != 0) { + getString(descriptionId) + } else { + requireArguments().getString(DESCRIPTION_STRING)!! + } + + val positiveButtonId = requireArguments().getInt(POSITIVE_BUTTON_TITLE_ID) + val positiveButtonString = requireArguments().getString(POSITIVE_BUTTON_TITLE_STRING)!! + val positiveButton = if (positiveButtonId != 0) { + getString(positiveButtonId) + } else if (positiveButtonString.isNotEmpty()) { + positiveButtonString + } else if (messageDialogViewModel.positiveAction != null) { + getString(android.R.string.ok) + } else { + getString(R.string.close) + } + + val negativeButtonId = requireArguments().getInt(NEGATIVE_BUTTON_TITLE_ID) + val negativeButtonString = requireArguments().getString(NEGATIVE_BUTTON_TITLE_STRING)!! + val negativeButton = if (negativeButtonId != 0) { + getString(negativeButtonId) + } else if (negativeButtonString.isNotEmpty()) { + negativeButtonString + } else { + getString(android.R.string.cancel) + } + + val helpLinkId = requireArguments().getInt(HELP_LINK) + val dismissible = requireArguments().getBoolean(DISMISSIBLE) + val clearPositiveAction = requireArguments().getBoolean(CLEAR_ACTIONS) + val showNegativeButton = requireArguments().getBoolean(SHOW_NEGATIVE_BUTTON) + + val builder = MaterialAlertDialogBuilder(requireContext()) + + if (clearPositiveAction) { + messageDialogViewModel.positiveAction = null + } + + builder.setPositiveButton(positiveButton) { _, _ -> + messageDialogViewModel.positiveAction?.invoke() + } + if (messageDialogViewModel.negativeAction != null || showNegativeButton) { + builder.setNegativeButton(negativeButton) { _, _ -> + messageDialogViewModel.negativeAction?.invoke() + } + } + + if (title.isNotEmpty()) builder.setTitle(title) + if (description.isNotEmpty()) { + builder.setMessage(Html.fromHtml(description, Html.FROM_HTML_MODE_LEGACY)) + } + + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.learn_more) { _, _ -> + openLink(getString(helpLinkId)) + } + } + + isCancelable = dismissible + + return builder.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 TITLE_STRING = "TitleString" + private const val DESCRIPTION_ID = "DescriptionId" + private const val DESCRIPTION_STRING = "DescriptionString" + private const val HELP_LINK = "Link" + private const val DISMISSIBLE = "Dismissible" + private const val CLEAR_ACTIONS = "ClearActions" + private const val POSITIVE_BUTTON_TITLE_ID = "PositiveButtonTitleId" + private const val POSITIVE_BUTTON_TITLE_STRING = "PositiveButtonTitleString" + private const val SHOW_NEGATIVE_BUTTON = "ShowNegativeButton" + private const val NEGATIVE_BUTTON_TITLE_ID = "NegativeButtonTitleId" + private const val NEGATIVE_BUTTON_TITLE_STRING = "NegativeButtonTitleString" + + /** + * Creates a new [MessageDialogFragment] instance. + * @param activity Activity that will hold a [MessageDialogViewModel] instance if using + * [positiveAction] or [negativeAction]. + * @param titleId String resource ID that will be used for the title. [titleString] used if 0. + * @param titleString String that will be used for the title. No title is set if empty. + * @param descriptionId String resource ID that will be used for the description. + * [descriptionString] used if 0. + * @param descriptionString String that will be used for the description. + * No description is set if empty. + * @param helpLinkId String resource ID that contains a help link. Will be added as a neutral + * button with the title R.string.help. + * @param dismissible Whether the dialog is dismissible or not. Typically used to ensure that + * the user clicks on one of the dialog buttons before closing. + * @param positiveButtonTitleId String resource ID that will be used for the positive button. + * [positiveButtonTitleString] used if 0. + * @param positiveButtonTitleString String that will be used for the positive button. + * android.R.string.close used if empty. android.R.string.ok will be used if [positiveAction] + * is not null. + * @param positiveAction Lambda to run when the positive button is clicked. + * @param showNegativeButton Normally the negative button isn't shown if there is no + * [negativeAction] set. This can override that behavior to always show a button. + * @param negativeButtonTitleId String resource ID that will be used for the negative button. + * [negativeButtonTitleString] used if 0. + * @param negativeButtonTitleString String that will be used for the negative button. + * android.R.string.cancel used if empty. + * @param negativeAction Lambda to run when the negative button is clicked + */ + fun newInstance( + activity: FragmentActivity? = null, + titleId: Int = 0, + titleString: String = "", + descriptionId: Int = 0, + descriptionString: String = "", + helpLinkId: Int = 0, + dismissible: Boolean = true, + positiveButtonTitleId: Int = 0, + positiveButtonTitleString: String = "", + positiveAction: (() -> Unit)? = null, + showNegativeButton: Boolean = false, + negativeButtonTitleId: Int = 0, + negativeButtonTitleString: String = "", + negativeAction: (() -> Unit)? = null + ): MessageDialogFragment { + var clearActions = false + if (activity != null) { + ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply { + clear() + this.positiveAction = positiveAction + this.negativeAction = negativeAction + } + } else { + clearActions = true + } + + if (activity == null && (positiveAction == null || negativeAction == null)) { + Log.warning("[$TAG] Tried to set action with no activity!") + } + + val dialog = MessageDialogFragment() + val bundle = Bundle().apply { + putInt(TITLE_ID, titleId) + putString(TITLE_STRING, titleString) + putInt(DESCRIPTION_ID, descriptionId) + putString(DESCRIPTION_STRING, descriptionString) + putInt(HELP_LINK, helpLinkId) + putBoolean(DISMISSIBLE, dismissible) + putBoolean(CLEAR_ACTIONS, clearActions) + putInt(POSITIVE_BUTTON_TITLE_ID, positiveButtonTitleId) + putString(POSITIVE_BUTTON_TITLE_STRING, positiveButtonTitleString) + putBoolean(SHOW_NEGATIVE_BUTTON, showNegativeButton) + putInt(NEGATIVE_BUTTON_TITLE_ID, negativeButtonTitleId) + putString(NEGATIVE_BUTTON_TITLE_STRING, negativeButtonTitleString) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt new file mode 100644 index 000000000..3478b9250 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R + +class PermissionDeniedDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int -> + openSettings() + } + .setNegativeButton(android.R.string.cancel, null) + .setTitle(R.string.permission_denied) + .setMessage(R.string.permission_denied_description) + .show() + } + + private fun openSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", requireActivity().packageName, null) + intent.data = uri + startActivity(intent) + } + + companion object { + const val TAG = "PermissionDeniedDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ProgressDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ProgressDialogFragment.kt new file mode 100644 index 000000000..ee3bb0386 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ProgressDialogFragment.kt @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import 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.ViewModelProvider +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding +import org.yuzu.yuzu_emu.model.TaskViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.collect + +class ProgressDialogFragment : DialogFragment() { + private val taskViewModel: TaskViewModel by activityViewModels() + + private lateinit var binding: DialogProgressBarBinding + + private val PROGRESS_BAR_RESOLUTION = 1000 + + 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) + binding.message.isSelected = true + taskViewModel.isComplete.collect(viewLifecycleOwner) { + 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() + } + } + taskViewModel.cancelled.collect(viewLifecycleOwner) { + if (it) { + dialog?.setTitle(R.string.cancelling) + } + } + taskViewModel.progress.collect(viewLifecycleOwner) { + if (it != 0.0) { + binding.progressBar.apply { + isIndeterminate = false + progress = ( + (it / taskViewModel.maxProgress.value) * + PROGRESS_BAR_RESOLUTION + ).toInt() + min = 0 + max = PROGRESS_BAR_RESOLUTION + } + } + } + taskViewModel.message.collect(viewLifecycleOwner) { + binding.message.setVisible(it.isNotEmpty()) + binding.message.text = it + } + } + + // 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)) + binding.progressBar.isIndeterminate = true + 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: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any + ): ProgressDialogFragment { + val dialog = ProgressDialogFragment() + 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/citron/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ResetSettingsDialogFragment.kt new file mode 100644 index 000000000..1b4b93ab8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/ResetSettingsDialogFragment.kt @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity + +class ResetSettingsDialogFragment : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val settingsActivity = requireActivity() as SettingsActivity + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.reset_all_settings) + .setMessage(R.string.reset_all_settings_description) + .setPositiveButton(android.R.string.ok) { _, _ -> + settingsActivity.onSettingsReset() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + companion object { + const val TAG = "ResetSettingsDialogFragment" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SearchFragment.kt new file mode 100644 index 000000000..662ae9760 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SearchFragment.kt @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.preference.PreferenceManager +import info.debatty.java.stringsimilarity.Jaccard +import info.debatty.java.stringsimilarity.JaroWinkler +import java.util.Locale +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.collect + +class SearchFragment : Fragment() { + private var _binding: FragmentSearchBinding? = null + private val binding get() = _binding!! + + private val gamesViewModel: GamesViewModel by activityViewModels() + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var preferences: SharedPreferences + + companion object { + private const val SEARCH_TEXT = "SearchText" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSearchBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + + if (savedInstanceState != null) { + binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) + } + + binding.gridGamesSearch.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } + + binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> + binding.clearButton.setVisible(text.toString().isNotEmpty()) + filterAndSearch() + } + + gamesViewModel.searchFocused.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setSearchFocused(false) } + ) { if (it) focusSearch() } + gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() } + gamesViewModel.searchedGames.collect(viewLifecycleOwner) { + (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) + binding.noResultsView.setVisible(it.isNotEmpty()) + } + + binding.clearButton.setOnClickListener { binding.searchText.setText("") } + + binding.searchBackground.setOnClickListener { focusSearch() } + + setInsets() + filterAndSearch() + } + + private inner class ScoredGame(val score: Double, val item: Game) + + private fun filterAndSearch() { + val baseList = gamesViewModel.games.value + val filteredList: List = when (binding.chipGroup.checkedChipId) { + R.id.chip_recently_played -> { + baseList.filter { + val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) + lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } + } + + R.id.chip_recently_added -> { + baseList.filter { + val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) + addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) + }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } + } + + R.id.chip_homebrew -> baseList.filter { it.isHomebrew } + + R.id.chip_retail -> baseList.filter { !it.isHomebrew } + + else -> baseList + } + + if (binding.searchText.text.toString().isEmpty() && + binding.chipGroup.checkedChipId != View.NO_ID + ) { + gamesViewModel.setSearchedGames(filteredList) + return + } + + val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) + val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() + val sortedList: List = filteredList.mapNotNull { game -> + val title = game.title.lowercase(Locale.getDefault()) + val score = searchAlgorithm.similarity(searchTerm, title) + if (score > 0.03) { + ScoredGame(score, game) + } else { + null + } + }.sortedByDescending { it.score }.map { it.item } + gamesViewModel.setSearchedGames(sortedList) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) + } + } + + private fun focusSearch() { + if (_binding != null) { + binding.searchText.requestFocus() + val imm = requireActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? + imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) + + binding.constraintSearch.updatePadding( + left = barInsets.left + cutoutInsets.left, + top = barInsets.top, + right = barInsets.right + cutoutInsets.right + ) + + binding.gridGamesSearch.updatePadding( + top = extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) + + val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + binding.frameSearch.updatePadding(left = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) + binding.noResultsView.updatePadding(left = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing + spacingNavigationRail, + right = chipSpacing + ) + mlpDivider.leftMargin = chipSpacing + spacingNavigationRail + mlpDivider.rightMargin = chipSpacing + } else { + binding.frameSearch.updatePadding(right = spacingNavigationRail) + binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) + binding.noResultsView.updatePadding(right = spacingNavigationRail) + binding.chipGroup.updatePadding( + left = chipSpacing, + right = chipSpacing + spacingNavigationRail + ) + mlpDivider.leftMargin = chipSpacing + mlpDivider.rightMargin = chipSpacing + spacingNavigationRail + } + binding.divider.layoutParams = mlpDivider + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupFragment.kt new file mode 100644 index 000000000..4f7548e98 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupFragment.kt @@ -0,0 +1,396 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.Manifest +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.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.transition.MaterialFadeThrough +import kotlinx.coroutines.launch +import org.yuzu.yuzu_emu.NativeLibrary +import java.io.File +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.adapters.SetupAdapter +import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.SetupCallback +import org.yuzu.yuzu_emu.model.SetupPage +import org.yuzu.yuzu_emu.model.StepState +import org.yuzu.yuzu_emu.ui.main.MainActivity +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.NativeConfig +import org.yuzu.yuzu_emu.utils.ViewUtils +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.collect + +class SetupFragment : Fragment() { + private var _binding: FragmentSetupBinding? = null + private val binding get() = _binding!! + + private val homeViewModel: HomeViewModel by activityViewModels() + + private lateinit var mainActivity: MainActivity + + private lateinit var hasBeenWarned: BooleanArray + + companion object { + const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" + const val KEY_BACK_VISIBILITY = "BackButtonVisibility" + const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + exitTransition = MaterialFadeThrough() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentSetupBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + mainActivity = requireActivity() as MainActivity + + homeViewModel.setNavigationVisibility(visible = false, animated = false) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (binding.viewPager2.currentItem > 0) { + pageBackward() + } else { + requireActivity().finish() + } + } + } + ) + + requireActivity().window.navigationBarColor = + ContextCompat.getColor(requireContext(), android.R.color.transparent) + + val pages = mutableListOf() + pages.apply { + add( + SetupPage( + R.drawable.ic_yuzu_title, + R.string.welcome, + R.string.welcome_description, + 0, + true, + R.string.get_started, + { pageForward() }, + false + ) + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add( + SetupPage( + R.drawable.ic_notification, + R.string.notifications, + R.string.notifications_description, + 0, + false, + R.string.give_permission, + { + notificationCallback = it + permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + }, + true, + R.string.notification_warning, + R.string.notification_warning_description, + 0, + { + if (NotificationManagerCompat.from(requireContext()) + .areNotificationsEnabled() + ) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + } + + add( + SetupPage( + R.drawable.ic_key, + R.string.keys, + R.string.keys_description, + R.drawable.ic_add, + true, + R.string.select_keys, + { + keyCallback = it + getProdKey.launch(arrayOf("*/*")) + }, + true, + R.string.install_prod_keys_warning, + R.string.install_prod_keys_warning_description, + R.string.install_prod_keys_warning_help, + { + val file = File(DirectoryInitialization.userDirectory + "/keys/prod.keys") + if (file.exists() && NativeLibrary.areKeysPresent()) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + 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) + }, + true, + R.string.add_games_warning, + R.string.add_games_warning_description, + R.string.add_games_warning_help, + { + if (NativeConfig.getGameDirs().isNotEmpty()) { + StepState.COMPLETE + } else { + StepState.INCOMPLETE + } + } + ) + ) + add( + SetupPage( + R.drawable.ic_check, + R.string.done, + R.string.done_description, + R.drawable.ic_arrow_forward, + false, + R.string.text_continue, + { finishSetup() }, + false + ) + ) + } + + homeViewModel.shouldPageForward.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setShouldPageForward(false) } + ) { if (it) pageForward() } + homeViewModel.gamesDirSelected.collect( + viewLifecycleOwner, + resetState = { homeViewModel.setGamesDirSelected(false) } + ) { if (it) gamesDirCallback.onStepCompleted() } + + 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) { + val stepState = currentPage.stepCompleted.invoke() + if (stepState != StepState.INCOMPLETE) { + pageForward() + return@setOnClickListener + } + + if (!hasBeenWarned[index]) { + SetupWarningDialogFragment.newInstance( + currentPage.warningTitleId, + currentPage.warningDescriptionId, + currentPage.warningHelpLinkId, + index + ).show(childFragmentManager, SetupWarningDialogFragment.TAG) + return@setOnClickListener + } + } + pageForward() + } + binding.buttonBack.setOnClickListener { pageBackward() } + + if (savedInstanceState != null) { + val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) + val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) + hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! + + binding.buttonNext.setVisible(nextIsVisible) + binding.buttonBack.setVisible(backIsVisible) + } else { + hasBeenWarned = BooleanArray(pages.size) + } + + setInsets() + } + + override fun onStop() { + super.onStop() + NativeConfig.saveGlobalConfig() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (_binding != null) { + 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 + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private val permissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + notificationCallback.onStepCompleted() + } + + if (!it && + !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + ) { + PermissionDeniedDialogFragment().show( + childFragmentManager, + PermissionDeniedDialogFragment.TAG + ) + } + } + + private lateinit var keyCallback: SetupCallback + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + mainActivity.processKey(result) + if (NativeLibrary.areKeysPresent()) { + keyCallback.onStepCompleted() + } + } + } + + private lateinit var gamesDirCallback: SetupCallback + + val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + mainActivity.processGamesDir(result) + } + } + + private fun finishSetup() { + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() + .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) + .apply() + mainActivity.finishSetup(binding.root.findNavController()) + } + + fun pageForward() { + if (_binding != null) { + binding.viewPager2.currentItem += 1 + } + } + + fun pageBackward() { + if (_binding != null) { + 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 + .updatePadding( + left = leftPadding, + right = rightPadding, + bottom = bottomPadding + ) + } + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupWarningDialogFragment.kt new file mode 100644 index 000000000..b2c1d54af --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/fragments/SetupWarningDialogFragment.kt @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.fragments + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.yuzu.yuzu_emu.R + +class SetupWarningDialogFragment : DialogFragment() { + private var titleId: Int = 0 + private var descriptionId: Int = 0 + private var helpLinkId: Int = 0 + private var page: Int = 0 + + private lateinit var setupFragment: SetupFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + titleId = requireArguments().getInt(TITLE) + descriptionId = requireArguments().getInt(DESCRIPTION) + helpLinkId = requireArguments().getInt(HELP_LINK) + page = requireArguments().getInt(PAGE) + + setupFragment = requireParentFragment() as SetupFragment + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int -> + setupFragment.pageForward() + setupFragment.setPageWarned(page) + } + .setNegativeButton(R.string.warning_cancel, null) + + if (titleId != 0) { + builder.setTitle(titleId) + } else { + builder.setTitle("") + } + if (descriptionId != 0) { + builder.setMessage(descriptionId) + } + if (helpLinkId != 0) { + builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int -> + val helpLink = resources.getString(R.string.install_prod_keys_warning_help) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink)) + startActivity(intent) + } + } + + return builder.show() + } + + companion object { + const val TAG = "SetupWarningDialogFragment" + + private const val TITLE = "Title" + private const val DESCRIPTION = "Description" + private const val HELP_LINK = "HelpLink" + private const val PAGE = "Page" + + fun newInstance( + titleId: Int, + descriptionId: Int, + helpLinkId: Int, + page: Int + ): SetupWarningDialogFragment { + val dialog = SetupWarningDialogFragment() + val bundle = Bundle() + bundle.apply { + putInt(TITLE, titleId) + putInt(DESCRIPTION, descriptionId) + putInt(HELP_LINK, helpLinkId) + putInt(PAGE, page) + } + dialog.arguments = bundle + return dialog + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/layout/AutofitGridLayoutManager.kt new file mode 100644 index 000000000..bdd6ea628 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/layout/AutofitGridLayoutManager.kt @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.layout + +import android.content.Context +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.Recycler +import org.yuzu.yuzu_emu.R + +/** + * Cut down version of the solution provided here + * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count + */ +class AutofitGridLayoutManager( + context: Context, + columnWidth: Int +) : GridLayoutManager(context, 1) { + private var columnWidth = 0 + private var isColumnWidthChanged = true + private var lastWidth = 0 + private var lastHeight = 0 + + init { + setColumnWidth(checkedColumnWidth(context, columnWidth)) + } + + private fun checkedColumnWidth(context: Context, columnWidth: Int): Int { + var newColumnWidth = columnWidth + if (newColumnWidth <= 0) { + newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) + } + return newColumnWidth + } + + private fun setColumnWidth(newColumnWidth: Int) { + if (newColumnWidth > 0 && newColumnWidth != columnWidth) { + columnWidth = newColumnWidth + isColumnWidthChanged = true + } + } + + override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) { + val width = width + val height = height + if (columnWidth > 0 && width > 0 && height > 0 && + (isColumnWidthChanged || lastWidth != width || lastHeight != height) + ) { + val totalSpace: Int = if (orientation == VERTICAL) { + width - paddingRight - paddingLeft + } else { + height - paddingTop - paddingBottom + } + val spanCount = 1.coerceAtLeast(totalSpace / columnWidth) + setSpanCount(spanCount) + isColumnWidthChanged = false + } + lastWidth = width + lastHeight = height + super.onLayoutChildren(recycler, state) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/AddonViewModel.kt new file mode 100644 index 000000000..b9c8e49ca --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/AddonViewModel.kt @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class AddonViewModel : ViewModel() { + private val _patchList = MutableStateFlow(mutableListOf()) + val addonList get() = _patchList.asStateFlow() + + private val _showModInstallPicker = MutableStateFlow(false) + val showModInstallPicker get() = _showModInstallPicker.asStateFlow() + + private val _showModNoticeDialog = MutableStateFlow(false) + val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() + + private val _addonToDelete = MutableStateFlow(null) + val addonToDelete = _addonToDelete.asStateFlow() + + var game: Game? = null + + private val isRefreshing = AtomicBoolean(false) + + fun onOpenAddons(game: Game) { + this.game = game + refreshAddons() + } + + fun refreshAddons() { + if (isRefreshing.get() || game == null) { + return + } + isRefreshing.set(true) + viewModelScope.launch { + withContext(Dispatchers.IO) { + val patchList = ( + NativeLibrary.getPatchesForFile(game!!.path, game!!.programId) + ?: emptyArray() + ).toMutableList() + patchList.sortBy { it.name } + _patchList.value = patchList + isRefreshing.set(false) + } + } + } + + fun setAddonToDelete(patch: Patch?) { + _addonToDelete.value = patch + } + + fun onDeleteAddon(patch: Patch) { + when (PatchType.from(patch.type)) { + PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) + PatchType.DLC -> NativeLibrary.removeDLC(patch.programId) + PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name) + } + refreshAddons() + } + + fun onCloseAddons() { + if (_patchList.value.isEmpty()) { + return + } + + NativeConfig.setDisabledAddons( + game!!.programId, + _patchList.value.mapNotNull { + if (it.enabled) { + null + } else { + it.name + } + }.toTypedArray() + ) + NativeConfig.saveGlobalConfig() + _patchList.value.clear() + game = null + } + + fun showModInstallPicker(install: Boolean) { + _showModInstallPicker.value = install + } + + fun showModNoticeDialog(show: Boolean) { + _showModNoticeDialog.value = show + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/Applet.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Applet.kt new file mode 100644 index 000000000..8677674a3 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Applet.kt @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import org.yuzu.yuzu_emu.R + +data class Applet( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + @DrawableRes val iconId: Int, + val appletInfo: AppletInfo, + val cabinetMode: CabinetMode = CabinetMode.None +) + +// Combination of Common::AM::Applets::AppletId enum and the entry id +enum class AppletInfo(val appletId: Int, val entryId: Long = 0) { + None(0x00), + Application(0x01), + OverlayDisplay(0x02), + QLaunch(0x03), + Starter(0x04), + Auth(0x0A), + Cabinet(0x0B, 0x0100000000001002), + Controller(0x0C), + DataErase(0x0D), + Error(0x0E), + NetConnect(0x0F), + ProfileSelect(0x10), + SoftwareKeyboard(0x11), + MiiEdit(0x12, 0x0100000000001009), + Web(0x13), + Shop(0x14), + PhotoViewer(0x015, 0x010000000000100D), + Settings(0x16), + OfflineWeb(0x17), + LoginShare(0x18), + WebAuth(0x19), + MyPage(0x1A) +} + +// Matches enum in Service::NFP::CabinetMode with extra metadata +enum class CabinetMode( + val id: Int, + @StringRes val titleId: Int = 0, + @DrawableRes val iconId: Int = 0 +) { + None(-1), + StartNicknameAndOwnerSettings(0, R.string.cabinet_nickname_and_owner, R.drawable.ic_edit), + StartGameDataEraser(1, R.string.cabinet_game_data_eraser, R.drawable.ic_refresh), + StartRestorer(2, R.string.cabinet_restorer, R.drawable.ic_restore), + StartFormatter(3, R.string.cabinet_formatter, R.drawable.ic_clear) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/Driver.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Driver.kt new file mode 100644 index 000000000..de342212a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Driver.kt @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import org.yuzu.yuzu_emu.utils.GpuDriverMetadata + +data class Driver( + override var selected: Boolean, + val title: String, + val version: String = "", + val description: String = "" +) : SelectableItem { + override fun onSelectionStateChanged(selected: Boolean) { + this.selected = selected + } + + companion object { + fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver = + Driver( + selected, + this.name ?: "", + this.version ?: "", + this.description ?: "" + ) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/DriverViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/DriverViewModel.kt new file mode 100644 index 000000000..a49c887a1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/DriverViewModel.kt @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile +import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver +import org.yuzu.yuzu_emu.utils.GpuDriverHelper +import org.yuzu.yuzu_emu.utils.GpuDriverMetadata +import org.yuzu.yuzu_emu.utils.NativeConfig +import java.io.File + +class DriverViewModel : ViewModel() { + private val _areDriversLoading = MutableStateFlow(false) + private val _isDriverReady = MutableStateFlow(true) + private val _isDeletingDrivers = MutableStateFlow(false) + + val isInteractionAllowed: StateFlow = + combine( + _areDriversLoading, + _isDriverReady, + _isDeletingDrivers + ) { loading, ready, deleting -> + !loading && ready && !deleting + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = false) + + var driverData = GpuDriverHelper.getDrivers() + + private val _driverList = MutableStateFlow(emptyList()) + val driverList: StateFlow> get() = _driverList + + // Used for showing which driver is currently installed within the driver manager card + private val _selectedDriverTitle = MutableStateFlow("") + val selectedDriverTitle: StateFlow get() = _selectedDriverTitle + + private val _showClearButton = MutableStateFlow(false) + val showClearButton = _showClearButton.asStateFlow() + + private val driversToDelete = mutableListOf() + + init { + updateDriverList() + updateDriverNameForGame(null) + } + + fun reloadDriverData() { + _areDriversLoading.value = true + driverData = GpuDriverHelper.getDrivers() + updateDriverList() + _areDriversLoading.value = false + } + + fun updateDriverList() { + val selectedDriver = GpuDriverHelper.customDriverSettingData + val systemDriverData = GpuDriverHelper.getSystemDriverInfo() + val newDriverList = mutableListOf( + Driver( + selectedDriver == GpuDriverMetadata(), + YuzuApplication.appContext.getString(R.string.system_gpu_driver), + systemDriverData?.get(0) ?: "", + systemDriverData?.get(1) ?: "" + ) + ) + driverData.forEach { + newDriverList.add(it.second.toDriver(it.second == selectedDriver)) + } + _driverList.value = newDriverList + } + + fun onOpenDriverManager(game: Game?) { + if (game != null) { + SettingsFile.loadCustomConfig(game) + } + updateDriverList() + } + + fun showClearButton(value: Boolean) { + _showClearButton.value = value + } + + fun onDriverSelected(position: Int) { + if (position == 0) { + StringSetting.DRIVER_PATH.setString("") + } else { + StringSetting.DRIVER_PATH.setString(driverData[position - 1].first) + } + } + + fun onDriverRemoved(removedPosition: Int, selectedPosition: Int) { + driversToDelete.add(driverData[removedPosition - 1].first) + driverData.removeAt(removedPosition - 1) + onDriverSelected(selectedPosition) + } + + fun onDriverAdded(driver: Pair) { + if (driversToDelete.contains(driver.first)) { + driversToDelete.remove(driver.first) + } + driverData.add(driver) + onDriverSelected(driverData.size) + } + + fun onCloseDriverManager(game: Game?) { + _isDeletingDrivers.value = true + updateDriverNameForGame(game) + if (game == null) { + NativeConfig.saveGlobalConfig() + } else { + NativeConfig.savePerGameConfig() + NativeConfig.unloadPerGameConfig() + NativeConfig.reloadGlobalConfig() + } + + viewModelScope.launch { + withContext(Dispatchers.IO) { + driversToDelete.forEach { + val driver = File(it) + if (driver.exists()) { + driver.delete() + } + } + driversToDelete.clear() + _isDeletingDrivers.value = false + } + } + } + + // It is the Emulation Fragment's responsibility to load per-game settings so that this function + // knows what driver to load. + fun onLaunchGame() { + _isDriverReady.value = false + + val selectedDriverFile = File(StringSetting.DRIVER_PATH.getString()) + val selectedDriverMetadata = GpuDriverHelper.customDriverSettingData + if (GpuDriverHelper.installedCustomDriverData == selectedDriverMetadata) { + setDriverReady() + return + } + + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (selectedDriverMetadata.name == null) { + GpuDriverHelper.installDefaultDriver() + setDriverReady() + return@withContext + } + + if (selectedDriverFile.exists()) { + GpuDriverHelper.installCustomDriver(selectedDriverFile) + } else { + GpuDriverHelper.installDefaultDriver() + } + setDriverReady() + } + } + } + + fun updateDriverNameForGame(game: Game?) { + if (!GpuDriverHelper.supportsCustomDriverLoading()) { + return + } + + if (game == null || NativeConfig.isPerGameConfigLoaded()) { + updateName() + } else { + SettingsFile.loadCustomConfig(game) + updateName() + NativeConfig.unloadPerGameConfig() + NativeConfig.reloadGlobalConfig() + } + } + + private fun updateName() { + _selectedDriverTitle.value = GpuDriverHelper.customDriverSettingData.name + ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver) + } + + private fun setDriverReady() { + _isDriverReady.value = true + updateName() + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/EmulationViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/EmulationViewModel.kt new file mode 100644 index 000000000..d024493cd --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/EmulationViewModel.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class EmulationViewModel : ViewModel() { + val emulationStarted: StateFlow get() = _emulationStarted + private val _emulationStarted = MutableStateFlow(false) + + val isEmulationStopping: StateFlow get() = _isEmulationStopping + private val _isEmulationStopping = MutableStateFlow(false) + + private val _emulationStopped = MutableStateFlow(false) + val emulationStopped = _emulationStopped.asStateFlow() + + private val _programChanged = MutableStateFlow(-1) + val programChanged = _programChanged.asStateFlow() + + val shaderProgress: StateFlow get() = _shaderProgress + private val _shaderProgress = MutableStateFlow(0) + + val totalShaders: StateFlow get() = _totalShaders + private val _totalShaders = MutableStateFlow(0) + + val shaderMessage: StateFlow get() = _shaderMessage + private val _shaderMessage = MutableStateFlow("") + + private val _drawerOpen = MutableStateFlow(false) + val drawerOpen = _drawerOpen.asStateFlow() + + fun setEmulationStarted(started: Boolean) { + _emulationStarted.value = started + } + + fun setIsEmulationStopping(value: Boolean) { + _isEmulationStopping.value = value + } + + fun setEmulationStopped(value: Boolean) { + if (value) { + _emulationStarted.value = false + } + _emulationStopped.value = value + } + + fun setProgramChanged(programIndex: Int) { + _programChanged.value = programIndex + } + + fun setShaderProgress(progress: Int) { + _shaderProgress.value = progress + } + + fun setTotalShaders(max: Int) { + _totalShaders.value = max + } + + fun setShaderMessage(msg: String) { + _shaderMessage.value = msg + } + + fun updateProgress(msg: String, progress: Int, max: Int) { + setShaderMessage(msg) + setShaderProgress(progress) + setTotalShaders(max) + } + + fun setDrawerOpen(value: Boolean) { + _drawerOpen.value = value + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Game.kt new file mode 100644 index 000000000..6859b7780 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Game.kt @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.content.Intent +import android.net.Uri +import android.os.Parcelable +import java.util.HashSet +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.activities.EmulationActivity +import org.yuzu.yuzu_emu.utils.DirectoryInitialization +import org.yuzu.yuzu_emu.utils.FileUtil +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Parcelize +@Serializable +class Game( + val title: String = "", + val path: String, + val programId: String = "", + val developer: String = "", + var version: String = "", + val isHomebrew: Boolean = false +) : Parcelable { + val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime" + val keyLastPlayedTime get() = "${path}_LastPlayed" + + val settingsName: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + FileUtil.getFilename(Uri.parse(path)) + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val programIdHex: String + get() { + val programIdLong = programId.toLong() + return if (programIdLong == 0L) { + "0" + } else { + "0" + programIdLong.toString(16).uppercase() + } + } + + val saveZipName: String + get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${ + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + }.zip" + + val saveDir: String + get() = DirectoryInitialization.userDirectory + "/nand" + + NativeLibrary.getSavePath(programId) + + val addonDir: String + get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/" + + val launchIntent: Intent + get() = Intent(YuzuApplication.appContext, EmulationActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = Uri.parse(path) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Game + + if (title != other.title) return false + if (path != other.path) return false + if (programId != other.programId) return false + if (developer != other.developer) return false + if (version != other.version) return false + if (isHomebrew != other.isHomebrew) return false + + return true + } + + override fun hashCode(): Int { + var result = title.hashCode() + result = 31 * result + path.hashCode() + result = 31 * result + programId.hashCode() + result = 31 * result + developer.hashCode() + result = 31 * result + version.hashCode() + result = 31 * result + isHomebrew.hashCode() + return result + } + + companion object { + val extensions: Set = HashSet( + listOf("xci", "nsp", "nca", "nro") + ) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameDir.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameDir.kt new file mode 100644 index 000000000..274bc1c7b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameDir.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class GameDir( + val uriString: String, + var deepScan: Boolean +) : Parcelable diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameProperties.kt new file mode 100644 index 000000000..0135a95be --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameProperties.kt @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import kotlinx.coroutines.flow.StateFlow + +interface GameProperty { + @get:StringRes + val titleId: Int + + @get:StringRes + val descriptionId: Int + + @get:DrawableRes + val iconId: Int +} + +data class SubmenuProperty( + override val titleId: Int, + override val descriptionId: Int, + override val iconId: Int, + val details: (() -> String)? = null, + val detailsFlow: StateFlow? = null, + val action: () -> Unit +) : GameProperty + +data class InstallableProperty( + override val titleId: Int, + override val descriptionId: Int, + override val iconId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) : GameProperty diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameVerificationResult.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameVerificationResult.kt new file mode 100644 index 000000000..804637fb8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GameVerificationResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +enum class GameVerificationResult(val int: Int) { + Success(0), + Failed(1), + NotImplemented(2); + + companion object { + fun from(int: Int): GameVerificationResult = + entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GamesViewModel.kt new file mode 100644 index 000000000..5ae05b5cc --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/GamesViewModel.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.preference.PreferenceManager +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.utils.GameHelper +import org.yuzu.yuzu_emu.utils.NativeConfig +import java.util.concurrent.atomic.AtomicBoolean + +class GamesViewModel : ViewModel() { + val games: StateFlow> get() = _games + private val _games = MutableStateFlow(emptyList()) + + val searchedGames: StateFlow> get() = _searchedGames + private val _searchedGames = MutableStateFlow(emptyList()) + + val isReloading: StateFlow get() = _isReloading + private val _isReloading = MutableStateFlow(false) + + private val reloading = AtomicBoolean(false) + + val shouldSwapData: StateFlow get() = _shouldSwapData + private val _shouldSwapData = MutableStateFlow(false) + + val shouldScrollToTop: StateFlow get() = _shouldScrollToTop + private val _shouldScrollToTop = MutableStateFlow(false) + + val searchFocused: StateFlow get() = _searchFocused + private val _searchFocused = MutableStateFlow(false) + + private val _folders = MutableStateFlow(mutableListOf()) + val folders = _folders.asStateFlow() + + init { + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + getGameDirs() + reloadGames(directoriesChanged = false, firstStartup = true) + } + + fun setGames(games: List) { + val sortedList = games.sortedWith( + compareBy( + { it.title.lowercase(Locale.getDefault()) }, + { it.path } + ) + ) + + _games.value = sortedList + } + + fun setSearchedGames(games: List) { + _searchedGames.value = games + } + + fun setShouldSwapData(shouldSwap: Boolean) { + _shouldSwapData.value = shouldSwap + } + + fun setShouldScrollToTop(shouldScroll: Boolean) { + _shouldScrollToTop.value = shouldScroll + } + + fun setSearchFocused(searchFocused: Boolean) { + _searchFocused.value = searchFocused + } + + fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) { + if (reloading.get()) { + return + } + reloading.set(true) + _isReloading.value = true + + viewModelScope.launch { + withContext(Dispatchers.IO) { + if (firstStartup) { + // Retrieve list of cached games + val storedGames = + PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + .getStringSet(GameHelper.KEY_GAMES, emptySet()) + if (storedGames!!.isNotEmpty()) { + val deserializedGames = mutableSetOf() + storedGames.forEach { + val game: Game + try { + game = Json.decodeFromString(it) + } catch (e: Exception) { + // We don't care about any errors related to parsing the game cache + return@forEach + } + + val gameExists = + DocumentFile.fromSingleUri( + YuzuApplication.appContext, + Uri.parse(game.path) + )?.exists() + if (gameExists == true) { + deserializedGames.add(game) + } + } + setGames(deserializedGames.toList()) + } + } + + setGames(GameHelper.getGames()) + reloading.set(false) + _isReloading.value = false + + if (directoriesChanged) { + setShouldSwapData(true) + } + } + } + } + + fun addFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.addGameDir(gameDir) + getGameDirs(true) + } + } + + fun removeFolder(gameDir: GameDir) = + viewModelScope.launch { + withContext(Dispatchers.IO) { + val gameDirs = _folders.value.toMutableList() + val removedDirIndex = gameDirs.indexOf(gameDir) + if (removedDirIndex != -1) { + gameDirs.removeAt(removedDirIndex) + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + getGameDirs() + } + } + } + + fun updateGameDirs() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + NativeConfig.setGameDirs(_folders.value.toTypedArray()) + getGameDirs() + } + } + + fun onOpenGameFoldersFragment() = + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs() + } + } + + fun onCloseGameFoldersFragment() { + NativeConfig.saveGlobalConfig() + viewModelScope.launch { + withContext(Dispatchers.IO) { + getGameDirs(true) + } + } + } + + private fun getGameDirs(reloadList: Boolean = false) { + val gameDirs = NativeConfig.getGameDirs() + _folders.value = gameDirs.toMutableList() + if (reloadList) { + reloadGames(true) + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeSetting.kt new file mode 100644 index 000000000..b32e19373 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeSetting.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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/citron/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeViewModel.kt new file mode 100644 index 000000000..cfc777b81 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/HomeViewModel.kt @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class HomeViewModel : ViewModel() { + val navigationVisible: StateFlow> get() = _navigationVisible + private val _navigationVisible = MutableStateFlow(Pair(false, false)) + + val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible + private val _statusBarShadeVisible = MutableStateFlow(true) + + val shouldPageForward: StateFlow get() = _shouldPageForward + private val _shouldPageForward = MutableStateFlow(false) + + private val _gamesDirSelected = MutableStateFlow(false) + val gamesDirSelected get() = _gamesDirSelected.asStateFlow() + + private val _openImportSaves = MutableStateFlow(false) + val openImportSaves get() = _openImportSaves.asStateFlow() + + private val _contentToInstall = MutableStateFlow?>(null) + val contentToInstall get() = _contentToInstall.asStateFlow() + + private val _reloadPropertiesList = MutableStateFlow(false) + val reloadPropertiesList get() = _reloadPropertiesList.asStateFlow() + + private val _checkKeys = MutableStateFlow(false) + val checkKeys = _checkKeys.asStateFlow() + + var navigatedToSetup = false + + fun setNavigationVisibility(visible: Boolean, animated: Boolean) { + if (navigationVisible.value.first == visible) { + return + } + _navigationVisible.value = Pair(visible, animated) + } + + fun setStatusBarShadeVisibility(visible: Boolean) { + if (statusBarShadeVisible.value == visible) { + return + } + _statusBarShadeVisible.value = visible + } + + fun setShouldPageForward(pageForward: Boolean) { + _shouldPageForward.value = pageForward + } + + fun setGamesDirSelected(selected: Boolean) { + _gamesDirSelected.value = selected + } + + fun setOpenImportSaves(import: Boolean) { + _openImportSaves.value = import + } + + fun setContentToInstall(documents: List?) { + _contentToInstall.value = documents + } + + fun reloadPropertiesList(reload: Boolean) { + _reloadPropertiesList.value = reload + } + + fun setCheckKeys(value: Boolean) { + _checkKeys.value = value + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/InstallResult.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/InstallResult.kt new file mode 100644 index 000000000..0c3cd0521 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/InstallResult.kt @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +enum class InstallResult(val int: Int) { + Success(0), + Overwrite(1), + Failure(2), + BaseInstallAttempted(3); + + companion object { + fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Installable.kt new file mode 100644 index 000000000..36a7c97b8 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Installable.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.StringRes + +data class Installable( + @StringRes val titleId: Int, + @StringRes val descriptionId: Int, + val install: (() -> Unit)? = null, + val export: (() -> Unit)? = null +) diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/License.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/License.kt new file mode 100644 index 000000000..f24d5cf34 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/License.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class License( + val titleId: Int, + val descriptionId: Int, + val linkId: Int, + val copyrightId: Int, + val licenseId: Int +) : Parcelable diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/MessageDialogViewModel.kt new file mode 100644 index 000000000..2db005e49 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/MessageDialogViewModel.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.lifecycle.ViewModel + +class MessageDialogViewModel : ViewModel() { + var positiveAction: (() -> Unit)? = null + var negativeAction: (() -> Unit)? = null + + fun clear() { + positiveAction = null + negativeAction = null + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/MinimalDocumentFile.kt new file mode 100644 index 000000000..b4b78e42d --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/MinimalDocumentFile.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import android.net.Uri +import android.provider.DocumentsContract + +class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) { + val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/Patch.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Patch.kt new file mode 100644 index 000000000..25cb9e365 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/Patch.kt @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +import androidx.annotation.Keep + +@Keep +data class Patch( + var enabled: Boolean, + val name: String, + val version: String, + val type: Int, + val programId: String, + val titleId: String +) diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/PatchType.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/PatchType.kt new file mode 100644 index 000000000..e9a54162b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/PatchType.kt @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +enum class PatchType(val int: Int) { + Update(0), + DLC(1), + Mod(2); + + companion object { + fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/SelectableItem.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/SelectableItem.kt new file mode 100644 index 000000000..11c22d323 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/SelectableItem.kt @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +interface SelectableItem { + var selected: Boolean + fun onSelectionStateChanged(selected: Boolean) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/SetupPage.kt new file mode 100644 index 000000000..09a128ae6 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/SetupPage.kt @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +data class SetupPage( + val iconId: Int, + val titleId: Int, + val descriptionId: Int, + val buttonIconId: Int, + val leftAlignedIcon: Boolean, + val buttonTextId: Int, + val buttonAction: (callback: SetupCallback) -> Unit, + val hasWarning: Boolean, + val warningTitleId: Int = 0, + val warningDescriptionId: Int = 0, + val warningHelpLinkId: Int = 0, + val stepCompleted: () -> StepState = { StepState.UNDEFINED } +) + +interface SetupCallback { + fun onStepCompleted() +} + +enum class StepState { + COMPLETE, + INCOMPLETE, + UNDEFINED +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/model/TaskViewModel.kt new file mode 100644 index 000000000..4361eb972 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/model/TaskViewModel.kt @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.model + +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 + +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) + + private val _progress = MutableStateFlow(0.0) + val progress = _progress.asStateFlow() + + private val _maxProgress = MutableStateFlow(0.0) + val maxProgress = _maxProgress.asStateFlow() + + private val _message = MutableStateFlow("") + val message = _message.asStateFlow() + + lateinit var task: suspend ( + progressCallback: (max: Long, progress: Long) -> Boolean, + messageCallback: (message: String) -> Unit + ) -> Any + + fun clear() { + _result.value = Any() + _isComplete.value = false + _isRunning.value = false + _cancelled.value = false + _progress.value = 0.0 + _maxProgress.value = 0.0 + _message.value = "" + } + + fun setCancelled(value: Boolean) { + _cancelled.value = value + } + + fun runTask() { + if (isRunning.value) { + return + } + _isRunning.value = true + + viewModelScope.launch(Dispatchers.IO) { + val res = task( + { max, progress -> + _maxProgress.value = max.toDouble() + _progress.value = progress.toDouble() + return@task cancelled.value + }, + { message -> + _message.value = message + } + ) + _result.value = res + _isComplete.value = true + _isRunning.value = false + } + } +} + +enum class TaskState { + Completed, + Failed, + Cancelled +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlay.kt new file mode 100644 index 000000000..737e03584 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlay.kt @@ -0,0 +1,1049 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.app.Activity +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.VectorDrawable +import android.os.Build +import android.util.AttributeSet +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.SurfaceView +import android.view.View +import android.view.View.OnTouchListener +import android.view.WindowInsets +import androidx.core.content.ContextCompat +import androidx.window.layout.WindowMetricsCalculator +import kotlin.math.max +import kotlin.math.min +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.overlay.model.OverlayControl +import org.yuzu.yuzu_emu.overlay.model.OverlayControlData +import org.yuzu.yuzu_emu.overlay.model.OverlayLayout +import org.yuzu.yuzu_emu.utils.NativeConfig + +/** + * Draws the interactive input overlay on top of the + * [SurfaceView] that is rendering emulation. + */ +class InputOverlay(context: Context, attrs: AttributeSet?) : + SurfaceView(context, attrs), + OnTouchListener { + private val overlayButtons: MutableSet = HashSet() + private val overlayDpads: MutableSet = HashSet() + private val overlayJoysticks: MutableSet = HashSet() + + private var inEditMode = false + private var buttonBeingConfigured: InputOverlayDrawableButton? = null + private var dpadBeingConfigured: InputOverlayDrawableDpad? = null + private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null + + private lateinit var windowInsets: WindowInsets + + var layout = OverlayLayout.Landscape + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + super.onLayout(changed, left, top, right, bottom) + + windowInsets = rootWindowInsets + + val overlayControlData = NativeConfig.getOverlayControlData() + if (overlayControlData.isEmpty()) { + populateDefaultConfig() + } else { + checkForNewControls(overlayControlData) + } + + // Load the controls. + refreshControls() + + // Set the on touch listener. + setOnTouchListener(this) + + // Force draw + setWillNotDraw(false) + + // Request focus for the overlay so it has priority on presses. + requestFocus() + } + + override fun draw(canvas: Canvas) { + super.draw(canvas) + for (button in overlayButtons) { + button.draw(canvas) + } + for (dpad in overlayDpads) { + dpad.draw(canvas) + } + for (joystick in overlayJoysticks) { + joystick.draw(canvas) + } + } + + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (inEditMode) { + return onTouchWhileEditing(event) + } + + var shouldUpdateView = false + val playerIndex = when (NativeInput.getStyleIndex(0)) { + NpadStyleIndex.Handheld -> 8 + else -> 0 + } + + for (button in overlayButtons) { + if (!button.updateStatus(event)) { + continue + } + NativeInput.onOverlayButtonEvent( + playerIndex, + button.button, + button.status + ) + playHaptics(event) + shouldUpdateView = true + } + + for (dpad in overlayDpads) { + if (!dpad.updateStatus(event, BooleanSetting.DPAD_SLIDE.getBoolean())) { + continue + } + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.up, + dpad.upStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.down, + dpad.downStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.left, + dpad.leftStatus + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + dpad.right, + dpad.rightStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + for (joystick in overlayJoysticks) { + if (!joystick.updateStatus(event)) { + continue + } + NativeInput.onOverlayJoystickEvent( + playerIndex, + joystick.joystick, + joystick.xAxis, + joystick.realYAxis + ) + NativeInput.onOverlayButtonEvent( + playerIndex, + joystick.button, + joystick.buttonStatus + ) + playHaptics(event) + shouldUpdateView = true + } + + if (shouldUpdateView) { + invalidate() + } + + if (!BooleanSetting.TOUCHSCREEN.getBoolean()) { + return true + } + + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionMove = motionEvent == MotionEvent.ACTION_MOVE + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown && !isTouchInputConsumed(pointerId)) { + NativeInput.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) + } + + if (isActionMove) { + for (i in 0 until event.pointerCount) { + val fingerId = event.getPointerId(i) + if (isTouchInputConsumed(fingerId)) { + continue + } + NativeInput.onTouchMoved(fingerId, event.getX(i), event.getY(i)) + } + } + + if (isActionUp && !isTouchInputConsumed(pointerId)) { + NativeInput.onTouchReleased(pointerId) + } + + return true + } + + private fun playHaptics(event: MotionEvent) { + if (BooleanSetting.HAPTIC_FEEDBACK.getBoolean()) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> + performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) + } + } + } + + private fun isTouchInputConsumed(track_id: Int): Boolean { + for (button in overlayButtons) { + if (button.trackId == track_id) { + return true + } + } + for (dpad in overlayDpads) { + if (dpad.trackId == track_id) { + return true + } + } + for (joystick in overlayJoysticks) { + if (joystick.trackId == track_id) { + return true + } + } + return false + } + + private fun onTouchWhileEditing(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + for (button in overlayButtons) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + button.bounds.contains(fingerPositionX, fingerPositionY) + ) { + buttonBeingConfigured = button + buttonBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { + buttonBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) { + // Persist button position by saving new place. + saveControlPosition( + buttonBeingConfigured!!.overlayControlData.id, + buttonBeingConfigured!!.bounds.centerX(), + buttonBeingConfigured!!.bounds.centerY(), + layout + ) + buttonBeingConfigured = null + } + } + } + + for (dpad in overlayDpads) { + // Determine the button state to apply based on the MotionEvent action flag. + when (event.action and MotionEvent.ACTION_MASK) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> + // If no button is being moved now, remember the currently touched button to move. + if (buttonBeingConfigured == null && + dpad.bounds.contains(fingerPositionX, fingerPositionY) + ) { + dpadBeingConfigured = dpad + dpadBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { + dpadBeingConfigured!!.onConfigureTouch(event) + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) { + // Persist button position by saving new place. + saveControlPosition( + OverlayControl.COMBINED_DPAD.id, + dpadBeingConfigured!!.bounds.centerX(), + dpadBeingConfigured!!.bounds.centerY(), + layout + ) + dpadBeingConfigured = null + } + } + } + + for (joystick in overlayJoysticks) { + when (event.action) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && + joystick.bounds.contains(fingerPositionX, fingerPositionY) + ) { + joystickBeingConfigured = joystick + joystickBeingConfigured!!.onConfigureTouch(event) + } + + MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { + joystickBeingConfigured!!.onConfigureTouch(event) + invalidate() + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { + saveControlPosition( + joystickBeingConfigured!!.prefId, + joystickBeingConfigured!!.bounds.centerX(), + joystickBeingConfigured!!.bounds.centerY(), + layout + ) + joystickBeingConfigured = null + } + } + } + + return true + } + + private fun addOverlayControls(layout: OverlayLayout) { + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) + val overlayControlData = NativeConfig.getOverlayControlData() + for (data in overlayControlData) { + if (!data.enabled) { + continue + } + + val position = data.positionFromLayout(layout) + when (data.id) { + OverlayControl.BUTTON_A.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_a, + R.drawable.facebutton_a_depressed, + NativeButton.A, + data, + position + ) + ) + } + + OverlayControl.BUTTON_B.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_b, + R.drawable.facebutton_b_depressed, + NativeButton.B, + data, + position + ) + ) + } + + OverlayControl.BUTTON_X.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_x, + R.drawable.facebutton_x_depressed, + NativeButton.X, + data, + position + ) + ) + } + + OverlayControl.BUTTON_Y.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_y, + R.drawable.facebutton_y_depressed, + NativeButton.Y, + data, + position + ) + ) + } + + OverlayControl.BUTTON_PLUS.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_plus, + R.drawable.facebutton_plus_depressed, + NativeButton.Plus, + data, + position + ) + ) + } + + OverlayControl.BUTTON_MINUS.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_minus, + R.drawable.facebutton_minus_depressed, + NativeButton.Minus, + data, + position + ) + ) + } + + OverlayControl.BUTTON_HOME.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_home, + R.drawable.facebutton_home_depressed, + NativeButton.Home, + data, + position + ) + ) + } + + OverlayControl.BUTTON_CAPTURE.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.facebutton_screenshot, + R.drawable.facebutton_screenshot_depressed, + NativeButton.Capture, + data, + position + ) + ) + } + + OverlayControl.BUTTON_L.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.l_shoulder, + R.drawable.l_shoulder_depressed, + NativeButton.L, + data, + position + ) + ) + } + + OverlayControl.BUTTON_R.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.r_shoulder, + R.drawable.r_shoulder_depressed, + NativeButton.R, + data, + position + ) + ) + } + + OverlayControl.BUTTON_ZL.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zl_trigger, + R.drawable.zl_trigger_depressed, + NativeButton.ZL, + data, + position + ) + ) + } + + OverlayControl.BUTTON_ZR.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.zr_trigger, + R.drawable.zr_trigger_depressed, + NativeButton.ZR, + data, + position + ) + ) + } + + OverlayControl.BUTTON_STICK_L.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.button_l3, + R.drawable.button_l3_depressed, + NativeButton.LStick, + data, + position + ) + ) + } + + OverlayControl.BUTTON_STICK_R.id -> { + overlayButtons.add( + initializeOverlayButton( + context, + windowSize, + R.drawable.button_r3, + R.drawable.button_r3_depressed, + NativeButton.RStick, + data, + position + ) + ) + } + + OverlayControl.STICK_L.id -> { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + NativeAnalog.LStick, + NativeButton.LStick, + data, + position + ) + ) + } + + OverlayControl.STICK_R.id -> { + overlayJoysticks.add( + initializeOverlayJoystick( + context, + windowSize, + R.drawable.joystick_range, + R.drawable.joystick, + R.drawable.joystick_depressed, + NativeAnalog.RStick, + NativeButton.RStick, + data, + position + ) + ) + } + + OverlayControl.COMBINED_DPAD.id -> { + overlayDpads.add( + initializeOverlayDpad( + context, + windowSize, + R.drawable.dpad_standard, + R.drawable.dpad_standard_cardinal_depressed, + R.drawable.dpad_standard_diagonal_depressed, + position + ) + ) + } + } + } + } + + fun refreshControls() { + // Remove all the overlay buttons from the HashSet. + overlayButtons.clear() + overlayDpads.clear() + overlayJoysticks.clear() + + // Add all the enabled overlay items back to the HashSet. + if (BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean()) { + addOverlayControls(layout) + } + invalidate() + } + + private fun saveControlPosition(id: String, x: Int, y: Int, layout: OverlayLayout) { + val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) + val min = windowSize.first + val max = windowSize.second + val overlayControlData = NativeConfig.getOverlayControlData() + val data = overlayControlData.firstOrNull { it.id == id } + val newPosition = Pair((x - min.x).toDouble() / max.x, (y - min.y).toDouble() / max.y) + when (layout) { + OverlayLayout.Landscape -> data?.landscapePosition = newPosition + OverlayLayout.Portrait -> data?.portraitPosition = newPosition + OverlayLayout.Foldable -> data?.foldablePosition = newPosition + } + NativeConfig.setOverlayControlData(overlayControlData) + } + + fun setIsInEditMode(editMode: Boolean) { + inEditMode = editMode + } + + /** + * Applies and saves all default values for the overlay + */ + private fun populateDefaultConfig() { + val newConfig = OverlayControl.entries.map { it.toOverlayControlData() } + NativeConfig.setOverlayControlData(newConfig.toTypedArray()) + NativeConfig.saveGlobalConfig() + } + + /** + * Checks if any new controls were added to OverlayControl that do not exist within deserialized + * config and adds / saves them if necessary + * + * @param overlayControlData Overlay control data from [NativeConfig.getOverlayControlData] + */ + private fun checkForNewControls(overlayControlData: Array) { + val missingControls = mutableListOf() + OverlayControl.entries.forEach { defaultControl -> + val controlData = overlayControlData.firstOrNull { it.id == defaultControl.id } + if (controlData == null) { + missingControls.add(defaultControl.toOverlayControlData()) + } + } + + if (missingControls.isNotEmpty()) { + NativeConfig.setOverlayControlData( + arrayOf(*overlayControlData, *(missingControls.toTypedArray())) + ) + NativeConfig.saveGlobalConfig() + } + } + + fun resetLayoutVisibilityAndPlacement() { + defaultOverlayPositionByLayout(layout) + + val overlayControlData = NativeConfig.getOverlayControlData() + overlayControlData.forEach { + it.enabled = OverlayControl.from(it.id)?.defaultVisibility == true + } + NativeConfig.setOverlayControlData(overlayControlData) + + refreshControls() + } + + private fun defaultOverlayPositionByLayout(layout: OverlayLayout) { + val overlayControlData = NativeConfig.getOverlayControlData() + for (data in overlayControlData) { + val defaultControlData = OverlayControl.from(data.id) ?: continue + val position = defaultControlData.getDefaultPositionForLayout(layout) + when (layout) { + OverlayLayout.Landscape -> data.landscapePosition = position + OverlayLayout.Portrait -> data.portraitPosition = position + OverlayLayout.Foldable -> data.foldablePosition = position + } + } + NativeConfig.setOverlayControlData(overlayControlData) + } + + override fun isInEditMode(): Boolean { + return inEditMode + } + + companion object { + // Increase this number every time there is a breaking change to every overlay layout + const val OVERLAY_VERSION = 1 + + // Increase the corresponding layout version number whenever that layout has a breaking change + private const val LANDSCAPE_OVERLAY_VERSION = 1 + private const val PORTRAIT_OVERLAY_VERSION = 1 + private const val FOLDABLE_OVERLAY_VERSION = 1 + val overlayLayoutVersions = listOf( + LANDSCAPE_OVERLAY_VERSION, + PORTRAIT_OVERLAY_VERSION, + FOLDABLE_OVERLAY_VERSION + ) + + /** + * Resizes a [Bitmap] by a given scale factor + * + * @param context Context for getting the vector drawable + * @param drawableId The ID of the drawable to scale. + * @param scale The scale factor for the bitmap. + * @return The scaled [Bitmap] + */ + private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { + val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable + + val bitmap = Bitmap.createBitmap( + (vectorDrawable.intrinsicWidth * scale).toInt(), + (vectorDrawable.intrinsicHeight * scale).toInt(), + Bitmap.Config.ARGB_8888 + ) + + val dm = context.resources.displayMetrics + val minScreenDimension = min(dm.widthPixels, dm.heightPixels) + + val maxBitmapDimension = max(bitmap.width, bitmap.height) + val bitmapScale = scale * minScreenDimension / maxBitmapDimension + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + (bitmap.width * bitmapScale).toInt(), + (bitmap.height * bitmapScale).toInt(), + true + ) + + val canvas = Canvas(scaledBitmap) + vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) + vectorDrawable.draw(canvas) + return scaledBitmap + } + + /** + * Gets the safe screen size for drawing the overlay + * + * @param context Context for getting the window metrics + * @return A pair of points, the first being the top left corner of the safe area, + * the second being the bottom right corner of the safe area + */ + private fun getSafeScreenSize( + context: Context, + screenSize: Pair + ): Pair { + // Get screen size + val windowMetrics = WindowMetricsCalculator.getOrCreate() + .computeCurrentWindowMetrics(context as Activity) + var maxX = screenSize.first.toFloat() + var maxY = screenSize.second.toFloat() + var minX = 0 + var minY = 0 + + // If we have API access, calculate the safe area to draw the overlay + var cutoutLeft = 0 + var cutoutBottom = 0 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val insets = context.windowManager.currentWindowMetrics.windowInsets.displayCutout + if (insets != null) { + if (insets.boundingRectTop.bottom != 0 && + insets.boundingRectTop.bottom > maxY / 2 + ) { + maxY = insets.boundingRectTop.bottom.toFloat() + } + if (insets.boundingRectRight.left != 0 && + insets.boundingRectRight.left > maxX / 2 + ) { + maxX = insets.boundingRectRight.left.toFloat() + } + + minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left + minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom + + cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left + cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom + } + } + + // This makes sure that if we have an inset on one side of the screen, we mirror it on + // the other side. Since removing space from one of the max values messes with the scale, + // we also have to account for it using our min values. + if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft + if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom + if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) { + maxX -= (minX * 2) + } else if (minX > 0) { + maxX -= minX + } + if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) { + maxY -= (minY * 2) + } else if (minY > 0) { + maxY -= minY + } + + return Pair(Point(minX, minY), Point(maxX.toInt(), maxY.toInt())) + } + + /** + * Initializes an InputOverlayDrawableButton, given by resId, with all of the + * parameters set for it to be properly shown on the InputOverlay. + * + * + * This works due to the way the X and Y coordinates are stored within + * the [SharedPreferences]. + * + * + * In the input overlay configuration menu, + * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). + * the X and Y coordinates of the button at the END of its touch event + * (when you remove your finger/stylus from the touchscreen) are then stored in a native . + * + * Technically no modifications should need to be performed on the returned + * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait + * for Android to call the onDraw method. + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). + * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). + * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. + * @param overlayControlData Identifier for determining where a button appears on screen. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. + */ + private fun initializeOverlayButton( + context: Context, + windowSize: Pair, + defaultResId: Int, + pressedResId: Int, + button: NativeButton, + overlayControlData: OverlayControlData, + position: Pair + ): InputOverlayDrawableButton { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button preference ID and user preference + var scale: Float = when (overlayControlData.id) { + OverlayControl.BUTTON_HOME.id, + OverlayControl.BUTTON_CAPTURE.id, + OverlayControl.BUTTON_PLUS.id, + OverlayControl.BUTTON_MINUS.id -> 0.07f + + OverlayControl.BUTTON_L.id, + OverlayControl.BUTTON_R.id, + OverlayControl.BUTTON_ZL.id, + OverlayControl.BUTTON_ZR.id -> 0.26f + + OverlayControl.BUTTON_STICK_L.id, + OverlayControl.BUTTON_STICK_R.id -> 0.155f + + else -> 0.11f + } + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableButton. + val defaultStateBitmap = getBitmap(context, defaultResId, scale) + val pressedStateBitmap = getBitmap(context, pressedResId, scale) + val overlayDrawable = InputOverlayDrawableButton( + res, + defaultStateBitmap, + pressedStateBitmap, + button, + overlayControlData + ) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableButton. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition( + drawableX - (width / 2), + drawableY - (height / 2) + ) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableDpad] + * + * @param context The current [Context]. + * @param windowSize The size of the window to draw the overlay on. + * @param defaultResId The [Bitmap] resource ID of the default state. + * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed state in one direction. + * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed state in two directions. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return The initialized [InputOverlayDrawableDpad] + */ + private fun initializeOverlayDpad( + context: Context, + windowSize: Pair, + defaultResId: Int, + pressedOneDirectionResId: Int, + pressedTwoDirectionsResId: Int, + position: Pair + ): InputOverlayDrawableDpad { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on button ID and user preference + var scale = 0.25f + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableDpad. + val defaultStateBitmap = + getBitmap(context, defaultResId, scale) + val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) + val pressedTwoDirectionsStateBitmap = + getBitmap(context, pressedTwoDirectionsResId, scale) + + val overlayDrawable = InputOverlayDrawableDpad( + res, + defaultStateBitmap, + pressedOneDirectionStateBitmap, + pressedTwoDirectionsStateBitmap + ) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val width = overlayDrawable.width + val height = overlayDrawable.height + + // Now set the bounds for the InputOverlayDrawableDpad. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. + overlayDrawable.setBounds( + drawableX - (width / 2), + drawableY - (height / 2), + drawableX + (width / 2), + drawableY + (height / 2) + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX - (width / 2), drawableY - (height / 2)) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + + /** + * Initializes an [InputOverlayDrawableJoystick] + * + * @param context The current [Context] + * @param windowSize The size of the window to draw the overlay on. + * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). + * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). + * @param pressedResInner Resource ID for the pressed inner image of the joystick. + * @param joystick Identifier for which joystick this is. + * @param buttonId Identifier for which joystick button this is. + * @param overlayControlData Identifier for determining where a button appears on screen. + * @param position The position on screen as represented by an x and y value between 0 and 1. + * @return The initialized [InputOverlayDrawableJoystick]. + */ + private fun initializeOverlayJoystick( + context: Context, + windowSize: Pair, + resOuter: Int, + defaultResInner: Int, + pressedResInner: Int, + joystick: NativeAnalog, + button: NativeButton, + overlayControlData: OverlayControlData, + position: Pair + ): InputOverlayDrawableJoystick { + // Resources handle for fetching the initial Drawable resource. + val res = context.resources + + // Decide scale based on user preference + var scale = 0.3f + scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() + scale /= 100f + + // Initialize the InputOverlayDrawableJoystick. + val bitmapOuter = getBitmap(context, resOuter, scale) + val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f) + val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f) + + // Get the minimum and maximum coordinates of the screen where the button can be placed. + val min = windowSize.first + val max = windowSize.second + + // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. + // These were set in the input overlay configuration menu. + val drawableX = (position.first * max.x + min.x).toInt() + val drawableY = (position.second * max.y + min.y).toInt() + val outerScale = 1.66f + + // Now set the bounds for the InputOverlayDrawableJoystick. + // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. + val outerSize = bitmapOuter.width + val outerRect = Rect( + drawableX - (outerSize / 2), + drawableY - (outerSize / 2), + drawableX + (outerSize / 2), + drawableY + (outerSize / 2) + ) + val innerRect = + Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) + + // Send the drawableId to the joystick so it can be referenced when saving control position. + val overlayDrawable = InputOverlayDrawableJoystick( + res, + bitmapOuter, + bitmapInnerDefault, + bitmapInnerPressed, + outerRect, + innerRect, + joystick, + button, + overlayControlData.id + ) + + // Need to set the image's position + overlayDrawable.setPosition(drawableX, drawableY) + overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) + return overlayDrawable + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableButton.kt new file mode 100644 index 000000000..fee3d04ee --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableButton.kt @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.overlay.model.OverlayControlData + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. + * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. + * @param button [NativeButton] for this type of button. + */ +class InputOverlayDrawableButton( + res: Resources, + defaultStateBitmap: Bitmap, + pressedStateBitmap: Bitmap, + val button: NativeButton, + val overlayControlData: OverlayControlData +) { + // The ID value what motion event is tracking + var trackId: Int + + // The drawable position on the screen + private var buttonPositionX = 0 + private var buttonPositionY = 0 + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedStateBitmap: BitmapDrawable + private var pressedState = false + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap) + trackId = -1 + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + } + + /** + * Updates button status based on the motion event. + * + * @return true if value was changed + */ + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + trackId = pointerId + return true + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + trackId = -1 + return true + } + + return false + } + + fun setPosition(x: Int, y: Int) { + buttonPositionX = x + buttonPositionY = y + } + + fun draw(canvas: Canvas?) { + currentStateBitmapDrawable.draw(canvas!!) + } + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateBitmap else defaultStateBitmap + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedStateBitmap.alpha = value + } + + val status: Int + get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED + val bounds: Rect + get() = defaultStateBitmap.bounds +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableDpad.kt new file mode 100644 index 000000000..0cb6ff244 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableDpad.kt @@ -0,0 +1,266 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeButton + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param defaultStateBitmap [Bitmap] of the default state. + * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. + * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. + */ +class InputOverlayDrawableDpad( + res: Resources, + defaultStateBitmap: Bitmap, + pressedOneDirectionStateBitmap: Bitmap, + pressedTwoDirectionsStateBitmap: Bitmap +) { + /** + * Gets one of the InputOverlayDrawableDpad's button IDs. + * + * @return the requested InputOverlayDrawableDpad's button ID. + */ + // The ID identifying what type of button this Drawable represents. + val up = NativeButton.DUp + val down = NativeButton.DDown + val left = NativeButton.DLeft + val right = NativeButton.DRight + var trackId: Int + + val width: Int + val height: Int + + private val defaultStateBitmap: BitmapDrawable + private val pressedOneDirectionStateBitmap: BitmapDrawable + private val pressedTwoDirectionsStateBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + private var controlPositionX = 0 + private var controlPositionY = 0 + + private var upButtonState = false + private var downButtonState = false + private var leftButtonState = false + private var rightButtonState = false + + init { + this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) + this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap) + this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) + width = this.defaultStateBitmap.intrinsicWidth + height = this.defaultStateBitmap.intrinsicHeight + trackId = -1 + } + + fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + trackId = pointerId + } + if (isActionUp) { + if (trackId != pointerId) { + return false + } + trackId = -1 + upButtonState = false + downButtonState = false + leftButtonState = false + rightButtonState = false + return true + } + if (trackId == -1) { + return false + } + if (!dpad_slide && !isActionDown) { + return false + } + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = bounds.bottom.toFloat() + var maxX = bounds.right.toFloat() + touchX -= bounds.centerX().toFloat() + maxX -= bounds.centerX().toFloat() + touchY -= bounds.centerY().toFloat() + maxY -= bounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldUpState = upButtonState + val oldDownState = downButtonState + val oldLeftState = leftButtonState + val oldRightState = rightButtonState + + upButtonState = axisY < -VIRT_AXIS_DEADZONE + downButtonState = axisY > VIRT_AXIS_DEADZONE + leftButtonState = axisX < -VIRT_AXIS_DEADZONE + rightButtonState = axisX > VIRT_AXIS_DEADZONE + return oldUpState != upButtonState || + oldDownState != downButtonState || + oldLeftState != leftButtonState || + oldRightState != rightButtonState + } + return false + } + + fun draw(canvas: Canvas) { + val px = controlPositionX + width / 2 + val py = controlPositionY + height / 2 + + // Pressed up + if (upButtonState && !leftButtonState && !rightButtonState) { + pressedOneDirectionStateBitmap.draw(canvas) + return + } + + // Pressed down + if (downButtonState && !leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed left + if (leftButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed right + if (rightButtonState && !upButtonState && !downButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedOneDirectionStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed up left + if (upButtonState && leftButtonState && !rightButtonState) { + pressedTwoDirectionsStateBitmap.draw(canvas) + return + } + + // Pressed up right + if (upButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(90f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down right + if (downButtonState && !leftButtonState && rightButtonState) { + canvas.save() + canvas.rotate(180f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Pressed down left + if (downButtonState && leftButtonState && !rightButtonState) { + canvas.save() + canvas.rotate(270f, px.toFloat(), py.toFloat()) + pressedTwoDirectionsStateBitmap.draw(canvas) + canvas.restore() + return + } + + // Not pressed + defaultStateBitmap.draw(canvas) + } + + val upStatus: Int + get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val downStatus: Int + get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val leftStatus: Int + get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED + val rightStatus: Int + get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + setBounds( + controlPositionX, + controlPositionY, + width + controlPositionX, + height + controlPositionY + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + return true + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { + defaultStateBitmap.setBounds(left, top, right, bottom) + pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom) + pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom) + } + + fun setOpacity(value: Int) { + defaultStateBitmap.alpha = value + pressedOneDirectionStateBitmap.alpha = value + pressedTwoDirectionsStateBitmap.alpha = value + } + + val bounds: Rect + get() = defaultStateBitmap.bounds + + companion object { + const val VIRT_AXIS_DEADZONE = 0.5f + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt new file mode 100644 index 000000000..4b07107fc --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay + +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.view.MotionEvent +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState +import org.yuzu.yuzu_emu.features.input.model.NativeAnalog +import org.yuzu.yuzu_emu.features.input.model.NativeButton +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting + +/** + * Custom [BitmapDrawable] that is capable + * of storing it's own ID. + * + * @param res [Resources] instance. + * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. + * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. + * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. + * @param rectOuter [Rect] which represents the outer joystick bounds. + * @param rectInner [Rect] which represents the inner joystick bounds. + * @param joystick The [NativeAnalog] this Drawable represents. + * @param button The [NativeButton] this Drawable represents. + */ +class InputOverlayDrawableJoystick( + res: Resources, + bitmapOuter: Bitmap, + bitmapInnerDefault: Bitmap, + bitmapInnerPressed: Bitmap, + rectOuter: Rect, + rectInner: Rect, + val joystick: NativeAnalog, + val button: NativeButton, + val prefId: String +) { + // The ID value what motion event is tracking + var trackId = -1 + + var xAxis = 0f + private var yAxis = 0f + + val width: Int + val height: Int + + private var opacity: Int = 0 + + private var virtBounds: Rect + private var origBounds: Rect + + private val outerBitmap: BitmapDrawable + private val defaultStateInnerBitmap: BitmapDrawable + private val pressedStateInnerBitmap: BitmapDrawable + + private var previousTouchX = 0 + private var previousTouchY = 0 + var controlPositionX = 0 + var controlPositionY = 0 + + private val boundsBoxBitmap: BitmapDrawable + + private var pressedState = false + + // TODO: Add button support + val buttonStatus: Int + get() = ButtonState.RELEASED + var bounds: Rect + get() = outerBitmap.bounds + set(bounds) { + outerBitmap.bounds = bounds + } + + // Nintendo joysticks have y axis inverted + val realYAxis: Float + get() = -yAxis + + private val currentStateBitmapDrawable: BitmapDrawable + get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap + + init { + outerBitmap = BitmapDrawable(res, bitmapOuter) + defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) + pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) + boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) + width = bitmapOuter.width + height = bitmapOuter.height + bounds = rectOuter + defaultStateInnerBitmap.bounds = rectInner + pressedStateInnerBitmap.bounds = rectInner + virtBounds = bounds + origBounds = outerBitmap.copyBounds() + boundsBoxBitmap.alpha = 0 + boundsBoxBitmap.bounds = virtBounds + setInnerBounds() + } + + fun draw(canvas: Canvas?) { + outerBitmap.draw(canvas!!) + currentStateBitmapDrawable.draw(canvas) + boundsBoxBitmap.draw(canvas) + } + + fun updateStatus(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val xPosition = event.getX(pointerIndex).toInt() + val yPosition = event.getY(pointerIndex).toInt() + val pointerId = event.getPointerId(pointerIndex) + val motionEvent = event.action and MotionEvent.ACTION_MASK + val isActionDown = + motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN + val isActionUp = + motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP + + if (isActionDown) { + if (!bounds.contains(xPosition, yPosition)) { + return false + } + pressedState = true + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = opacity + if (BooleanSetting.JOYSTICK_REL_CENTER.getBoolean()) { + virtBounds.offset( + xPosition - virtBounds.centerX(), + yPosition - virtBounds.centerY() + ) + } + boundsBoxBitmap.bounds = virtBounds + trackId = pointerId + } + + if (isActionUp) { + if (trackId != pointerId) { + return false + } + pressedState = false + xAxis = 0.0f + yAxis = 0.0f + outerBitmap.alpha = opacity + boundsBoxBitmap.alpha = 0 + virtBounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + bounds = Rect( + origBounds.left, + origBounds.top, + origBounds.right, + origBounds.bottom + ) + setInnerBounds() + trackId = -1 + return true + } + + if (trackId == -1) return false + + for (i in 0 until event.pointerCount) { + if (trackId != event.getPointerId(i)) { + continue + } + var touchX = event.getX(i) + var touchY = event.getY(i) + var maxY = virtBounds.bottom.toFloat() + var maxX = virtBounds.right.toFloat() + touchX -= virtBounds.centerX().toFloat() + maxX -= virtBounds.centerX().toFloat() + touchY -= virtBounds.centerY().toFloat() + maxY -= virtBounds.centerY().toFloat() + val axisX = touchX / maxX + val axisY = touchY / maxY + val oldXAxis = xAxis + val oldYAxis = yAxis + + // Clamp the circle pad input to a circle + val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat() + var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat() + if (radius > 1.0f) { + radius = 1.0f + } + xAxis = cos(angle.toDouble()).toFloat() * radius + yAxis = sin(angle.toDouble()).toFloat() * radius + setInnerBounds() + return oldXAxis != xAxis && oldYAxis != yAxis + } + return false + } + + fun onConfigureTouch(event: MotionEvent): Boolean { + val pointerIndex = event.actionIndex + val fingerPositionX = event.getX(pointerIndex).toInt() + val fingerPositionY = event.getY(pointerIndex).toInt() + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + controlPositionX = fingerPositionX - (width / 2) + controlPositionY = fingerPositionY - (height / 2) + } + + MotionEvent.ACTION_MOVE -> { + controlPositionX += fingerPositionX - previousTouchX + controlPositionY += fingerPositionY - previousTouchY + bounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + virtBounds = Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + setInnerBounds() + bounds = Rect( + Rect( + controlPositionX, + controlPositionY, + outerBitmap.intrinsicWidth + controlPositionX, + outerBitmap.intrinsicHeight + controlPositionY + ) + ) + previousTouchX = fingerPositionX + previousTouchY = fingerPositionY + } + } + origBounds = outerBitmap.copyBounds() + return true + } + + private fun setInnerBounds() { + var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() + var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() + if (x > virtBounds.centerX() + virtBounds.width() / 2) { + x = + virtBounds.centerX() + virtBounds.width() / 2 + } + if (x < virtBounds.centerX() - virtBounds.width() / 2) { + x = + virtBounds.centerX() - virtBounds.width() / 2 + } + if (y > virtBounds.centerY() + virtBounds.height() / 2) { + y = + virtBounds.centerY() + virtBounds.height() / 2 + } + if (y < virtBounds.centerY() - virtBounds.height() / 2) { + y = + virtBounds.centerY() - virtBounds.height() / 2 + } + val width = pressedStateInnerBitmap.bounds.width() / 2 + val height = pressedStateInnerBitmap.bounds.height() / 2 + defaultStateInnerBitmap.setBounds( + x - width, + y - height, + x + width, + y + height + ) + pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds + } + + fun setPosition(x: Int, y: Int) { + controlPositionX = x + controlPositionY = y + } + + fun setOpacity(value: Int) { + opacity = value + + defaultStateInnerBitmap.alpha = value + pressedStateInnerBitmap.alpha = value + + if (trackId == -1) { + outerBitmap.alpha = value + boundsBoxBitmap.alpha = 0 + } else { + outerBitmap.alpha = 0 + boundsBoxBitmap.alpha = value + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControl.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControl.kt new file mode 100644 index 000000000..a0eeadf4b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControl.kt @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay.model + +import androidx.annotation.IntegerRes +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication + +enum class OverlayControl( + val id: String, + val defaultVisibility: Boolean, + @IntegerRes val defaultLandscapePositionResources: Pair, + @IntegerRes val defaultPortraitPositionResources: Pair, + @IntegerRes val defaultFoldablePositionResources: Pair +) { + BUTTON_A( + "button_a", + true, + Pair(R.integer.BUTTON_A_X, R.integer.BUTTON_A_Y), + Pair(R.integer.BUTTON_A_X_PORTRAIT, R.integer.BUTTON_A_Y_PORTRAIT), + Pair(R.integer.BUTTON_A_X_FOLDABLE, R.integer.BUTTON_A_Y_FOLDABLE) + ), + BUTTON_B( + "button_b", + true, + Pair(R.integer.BUTTON_B_X, R.integer.BUTTON_B_Y), + Pair(R.integer.BUTTON_B_X_PORTRAIT, R.integer.BUTTON_B_Y_PORTRAIT), + Pair(R.integer.BUTTON_B_X_FOLDABLE, R.integer.BUTTON_B_Y_FOLDABLE) + ), + BUTTON_X( + "button_x", + true, + Pair(R.integer.BUTTON_X_X, R.integer.BUTTON_X_Y), + Pair(R.integer.BUTTON_X_X_PORTRAIT, R.integer.BUTTON_X_Y_PORTRAIT), + Pair(R.integer.BUTTON_X_X_FOLDABLE, R.integer.BUTTON_X_Y_FOLDABLE) + ), + BUTTON_Y( + "button_y", + true, + Pair(R.integer.BUTTON_Y_X, R.integer.BUTTON_Y_Y), + Pair(R.integer.BUTTON_Y_X_PORTRAIT, R.integer.BUTTON_Y_Y_PORTRAIT), + Pair(R.integer.BUTTON_Y_X_FOLDABLE, R.integer.BUTTON_Y_Y_FOLDABLE) + ), + BUTTON_PLUS( + "button_plus", + true, + Pair(R.integer.BUTTON_PLUS_X, R.integer.BUTTON_PLUS_Y), + Pair(R.integer.BUTTON_PLUS_X_PORTRAIT, R.integer.BUTTON_PLUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_PLUS_X_FOLDABLE, R.integer.BUTTON_PLUS_Y_FOLDABLE) + ), + BUTTON_MINUS( + "button_minus", + true, + Pair(R.integer.BUTTON_MINUS_X, R.integer.BUTTON_MINUS_Y), + Pair(R.integer.BUTTON_MINUS_X_PORTRAIT, R.integer.BUTTON_MINUS_Y_PORTRAIT), + Pair(R.integer.BUTTON_MINUS_X_FOLDABLE, R.integer.BUTTON_MINUS_Y_FOLDABLE) + ), + BUTTON_HOME( + "button_home", + false, + Pair(R.integer.BUTTON_HOME_X, R.integer.BUTTON_HOME_Y), + Pair(R.integer.BUTTON_HOME_X_PORTRAIT, R.integer.BUTTON_HOME_Y_PORTRAIT), + Pair(R.integer.BUTTON_HOME_X_FOLDABLE, R.integer.BUTTON_HOME_Y_FOLDABLE) + ), + BUTTON_CAPTURE( + "button_capture", + false, + Pair(R.integer.BUTTON_CAPTURE_X, R.integer.BUTTON_CAPTURE_Y), + Pair(R.integer.BUTTON_CAPTURE_X_PORTRAIT, R.integer.BUTTON_CAPTURE_Y_PORTRAIT), + Pair(R.integer.BUTTON_CAPTURE_X_FOLDABLE, R.integer.BUTTON_CAPTURE_Y_FOLDABLE) + ), + BUTTON_L( + "button_l", + true, + Pair(R.integer.BUTTON_L_X, R.integer.BUTTON_L_Y), + Pair(R.integer.BUTTON_L_X_PORTRAIT, R.integer.BUTTON_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_L_X_FOLDABLE, R.integer.BUTTON_L_Y_FOLDABLE) + ), + BUTTON_R( + "button_r", + true, + Pair(R.integer.BUTTON_R_X, R.integer.BUTTON_R_Y), + Pair(R.integer.BUTTON_R_X_PORTRAIT, R.integer.BUTTON_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_R_X_FOLDABLE, R.integer.BUTTON_R_Y_FOLDABLE) + ), + BUTTON_ZL( + "button_zl", + true, + Pair(R.integer.BUTTON_ZL_X, R.integer.BUTTON_ZL_Y), + Pair(R.integer.BUTTON_ZL_X_PORTRAIT, R.integer.BUTTON_ZL_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZL_X_FOLDABLE, R.integer.BUTTON_ZL_Y_FOLDABLE) + ), + BUTTON_ZR( + "button_zr", + true, + Pair(R.integer.BUTTON_ZR_X, R.integer.BUTTON_ZR_Y), + Pair(R.integer.BUTTON_ZR_X_PORTRAIT, R.integer.BUTTON_ZR_Y_PORTRAIT), + Pair(R.integer.BUTTON_ZR_X_FOLDABLE, R.integer.BUTTON_ZR_Y_FOLDABLE) + ), + BUTTON_STICK_L( + "button_stick_l", + true, + Pair(R.integer.BUTTON_STICK_L_X, R.integer.BUTTON_STICK_L_Y), + Pair(R.integer.BUTTON_STICK_L_X_PORTRAIT, R.integer.BUTTON_STICK_L_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_L_X_FOLDABLE, R.integer.BUTTON_STICK_L_Y_FOLDABLE) + ), + BUTTON_STICK_R( + "button_stick_r", + true, + Pair(R.integer.BUTTON_STICK_R_X, R.integer.BUTTON_STICK_R_Y), + Pair(R.integer.BUTTON_STICK_R_X_PORTRAIT, R.integer.BUTTON_STICK_R_Y_PORTRAIT), + Pair(R.integer.BUTTON_STICK_R_X_FOLDABLE, R.integer.BUTTON_STICK_R_Y_FOLDABLE) + ), + STICK_L( + "stick_l", + true, + Pair(R.integer.STICK_L_X, R.integer.STICK_L_Y), + Pair(R.integer.STICK_L_X_PORTRAIT, R.integer.STICK_L_Y_PORTRAIT), + Pair(R.integer.STICK_L_X_FOLDABLE, R.integer.STICK_L_Y_FOLDABLE) + ), + STICK_R( + "stick_r", + true, + Pair(R.integer.STICK_R_X, R.integer.STICK_R_Y), + Pair(R.integer.STICK_R_X_PORTRAIT, R.integer.STICK_R_Y_PORTRAIT), + Pair(R.integer.STICK_R_X_FOLDABLE, R.integer.STICK_R_Y_FOLDABLE) + ), + COMBINED_DPAD( + "combined_dpad", + true, + Pair(R.integer.COMBINED_DPAD_X, R.integer.COMBINED_DPAD_Y), + Pair(R.integer.COMBINED_DPAD_X_PORTRAIT, R.integer.COMBINED_DPAD_Y_PORTRAIT), + Pair(R.integer.COMBINED_DPAD_X_FOLDABLE, R.integer.COMBINED_DPAD_Y_FOLDABLE) + ); + + fun getDefaultPositionForLayout(layout: OverlayLayout): Pair { + val rawResourcePair: Pair + YuzuApplication.appContext.resources.apply { + rawResourcePair = when (layout) { + OverlayLayout.Landscape -> { + Pair( + getInteger(this@OverlayControl.defaultLandscapePositionResources.first), + getInteger(this@OverlayControl.defaultLandscapePositionResources.second) + ) + } + + OverlayLayout.Portrait -> { + Pair( + getInteger(this@OverlayControl.defaultPortraitPositionResources.first), + getInteger(this@OverlayControl.defaultPortraitPositionResources.second) + ) + } + + OverlayLayout.Foldable -> { + Pair( + getInteger(this@OverlayControl.defaultFoldablePositionResources.first), + getInteger(this@OverlayControl.defaultFoldablePositionResources.second) + ) + } + } + } + + return Pair( + rawResourcePair.first.toDouble() / 1000, + rawResourcePair.second.toDouble() / 1000 + ) + } + + fun toOverlayControlData(): OverlayControlData = + OverlayControlData( + id, + defaultVisibility, + getDefaultPositionForLayout(OverlayLayout.Landscape), + getDefaultPositionForLayout(OverlayLayout.Portrait), + getDefaultPositionForLayout(OverlayLayout.Foldable) + ) + + companion object { + val map: HashMap by lazy { + val hashMap = hashMapOf() + entries.forEach { hashMap[it.id] = it } + hashMap + } + + fun from(id: String): OverlayControl? = map[id] + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlData.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlData.kt new file mode 100644 index 000000000..26cfeb1db --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlData.kt @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay.model + +data class OverlayControlData( + val id: String, + var enabled: Boolean, + var landscapePosition: Pair, + var portraitPosition: Pair, + var foldablePosition: Pair +) { + fun positionFromLayout(layout: OverlayLayout): Pair = + when (layout) { + OverlayLayout.Landscape -> landscapePosition + OverlayLayout.Portrait -> portraitPosition + OverlayLayout.Foldable -> foldablePosition + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlDefault.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlDefault.kt new file mode 100644 index 000000000..6bd74c82f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayControlDefault.kt @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay.model + +import androidx.annotation.IntegerRes + +data class OverlayControlDefault( + val buttonId: String, + @IntegerRes val landscapePositionResource: Pair, + @IntegerRes val portraitPositionResource: Pair, + @IntegerRes val foldablePositionResource: Pair +) diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayLayout.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayLayout.kt new file mode 100644 index 000000000..d728164e5 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/overlay/model/OverlayLayout.kt @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.overlay.model + +enum class OverlayLayout(val id: String) { + Landscape("Landscape"), + Portrait("Portrait"), + Foldable("Foldable") +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/GamesFragment.kt new file mode 100644 index 000000000..fadb20e39 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/GamesFragment.kt @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.google.android.material.color.MaterialColors +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.adapters.GameAdapter +import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding +import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins +import org.yuzu.yuzu_emu.utils.collect + +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 onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentGamesBinding.inflate(inflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + homeViewModel.setNavigationVisibility(visible = true, animated = true) + homeViewModel.setStatusBarShadeVisibility(true) + + binding.gridGames.apply { + layoutManager = AutofitGridLayoutManager( + requireContext(), + requireContext().resources.getDimensionPixelSize(R.dimen.card_width) + ) + adapter = GameAdapter(requireActivity() as AppCompatActivity) + } + + binding.swipeRefresh.apply { + // Add swipe down to refresh gesture + setOnRefreshListener { + gamesViewModel.reloadGames(false) + } + + // Set theme color to the refresh animation's background + setProgressBackgroundColorSchemeColor( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorPrimary + ) + ) + setColorSchemeColors( + MaterialColors.getColor( + binding.swipeRefresh, + com.google.android.material.R.attr.colorOnPrimary + ) + ) + + // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn + post { + if (_binding == null) { + return@post + } + binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value + } + } + + gamesViewModel.isReloading.collect(viewLifecycleOwner) { + binding.swipeRefresh.isRefreshing = it + binding.noticeText.setVisible( + visible = gamesViewModel.games.value.isEmpty() && !it, + gone = false + ) + } + gamesViewModel.games.collect(viewLifecycleOwner) { + (binding.gridGames.adapter as GameAdapter).submitList(it) + } + gamesViewModel.shouldSwapData.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setShouldSwapData(false) } + ) { + if (it) { + (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) + } + } + gamesViewModel.shouldScrollToTop.collect( + viewLifecycleOwner, + resetState = { gamesViewModel.setShouldScrollToTop(false) } + ) { if (it) scrollToTop() } + + setInsets() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun scrollToTop() { + if (_binding != null) { + binding.gridGames.smoothScrollToPosition(0) + } + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { view: View, windowInsets: WindowInsetsCompat -> + val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large) + val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) + val spacingNavigationRail = + resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) + + binding.gridGames.updatePadding( + top = barInsets.top + extraListSpacing, + bottom = barInsets.bottom + spacingNavigation + extraListSpacing + ) + + binding.swipeRefresh.setProgressViewEndTarget( + false, + barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) + ) + + val leftInsets = barInsets.left + cutoutInsets.left + val rightInsets = barInsets.right + cutoutInsets.right + val left: Int + val right: Int + if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { + left = leftInsets + spacingNavigationRail + right = rightInsets + } else { + left = leftInsets + right = rightInsets + spacingNavigationRail + } + binding.swipeRefresh.updateMargins(left = left, right = right) + + binding.noticeText.updatePadding(bottom = spacingNavigation) + + windowInsets + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/MainActivity.kt new file mode 100644 index 000000000..757463a0b --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/MainActivity.kt @@ -0,0 +1,692 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui.main + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.view.WindowManager +import android.view.animation.PathInterpolator +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupWithNavController +import androidx.preference.PreferenceManager +import com.google.android.material.color.MaterialColors +import com.google.android.material.navigation.NavigationBarView +import java.io.File +import java.io.FilenameFilter +import org.yuzu.yuzu_emu.HomeNavigationDirections +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.databinding.ActivityMainBinding +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment +import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment +import org.yuzu.yuzu_emu.fragments.MessageDialogFragment +import org.yuzu.yuzu_emu.model.AddonViewModel +import org.yuzu.yuzu_emu.model.DriverViewModel +import org.yuzu.yuzu_emu.model.GamesViewModel +import org.yuzu.yuzu_emu.model.HomeViewModel +import org.yuzu.yuzu_emu.model.InstallResult +import org.yuzu.yuzu_emu.model.TaskState +import org.yuzu.yuzu_emu.model.TaskViewModel +import org.yuzu.yuzu_emu.utils.* +import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +class MainActivity : AppCompatActivity(), ThemeProvider { + private lateinit var binding: ActivityMainBinding + + private val homeViewModel: HomeViewModel by viewModels() + private val gamesViewModel: GamesViewModel by viewModels() + private val taskViewModel: TaskViewModel by viewModels() + private val addonViewModel: AddonViewModel by viewModels() + private val driverViewModel: DriverViewModel by viewModels() + + override var themeId: Int = 0 + + private val CHECKED_DECRYPTION = "CheckedDecryption" + private var checkedDecryption = false + + override fun onCreate(savedInstanceState: Bundle?) { + val splashScreen = installSplashScreen() + splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } + + ThemeHelper.setTheme(this) + + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + if (savedInstanceState != null) { + checkedDecryption = savedInstanceState.getBoolean(CHECKED_DECRYPTION) + } + if (!checkedDecryption) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + if (!firstTimeSetup) { + checkKeys() + } + checkedDecryption = true + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) + + window.statusBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + window.navigationBarColor = + ContextCompat.getColor(applicationContext, android.R.color.transparent) + + binding.statusBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + if (InsetsHelper.getSystemGestureType(applicationContext) != + InsetsHelper.GESTURE_NAVIGATION + ) { + binding.navigationBarShade.setBackgroundColor( + ThemeHelper.getColorWithOpacity( + MaterialColors.getColor( + binding.root, + com.google.android.material.R.attr.colorSurface + ), + ThemeHelper.SYSTEM_BAR_ALPHA + ) + ) + } + + val navHostFragment = + supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment + setUpNavigation(navHostFragment.navController) + (binding.navigationView as NavigationBarView).setOnItemReselectedListener { + when (it.itemId) { + R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) + R.id.searchFragment -> gamesViewModel.setSearchFocused(true) + R.id.homeSettingsFragment -> { + val action = HomeNavigationDirections.actionGlobalSettingsActivity( + null, + Settings.MenuTag.SECTION_ROOT + ) + navHostFragment.navController.navigate(action) + } + } + } + + // Prevents navigation from being drawn for a short time on recreation if set to hidden + if (!homeViewModel.navigationVisible.value.first) { + binding.navigationView.setVisible(visible = false, gone = false) + binding.statusBarShade.setVisible(visible = false, gone = false) + } + + homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) } + homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) } + homeViewModel.contentToInstall.collect( + this, + resetState = { homeViewModel.setContentToInstall(null) } + ) { + if (it != null) { + installContent(it) + } + } + homeViewModel.checkKeys.collect(this, resetState = { homeViewModel.setCheckKeys(false) }) { + if (it) checkKeys() + } + + setInsets() + } + + private fun checkKeys() { + if (!NativeLibrary.areKeysPresent()) { + MessageDialogFragment.newInstance( + titleId = R.string.keys_missing, + descriptionId = R.string.keys_missing_description, + helpLinkId = R.string.keys_missing_help + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putBoolean(CHECKED_DECRYPTION, checkedDecryption) + } + + fun finishSetup(navController: NavController) { + navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + showNavigation(visible = true, animated = true) + } + + private fun setUpNavigation(navController: NavController) { + val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) + + if (firstTimeSetup && !homeViewModel.navigatedToSetup) { + navController.navigate(R.id.firstTimeSetupFragment) + homeViewModel.navigatedToSetup = true + } else { + (binding.navigationView as NavigationBarView).setupWithNavController(navController) + } + } + + private fun showNavigation(visible: Boolean, animated: Boolean) { + if (!animated) { + binding.navigationView.setVisible(visible) + return + } + + val smallLayout = resources.getBoolean(R.bool.small_layout) + binding.navigationView.animate().apply { + if (visible) { + binding.navigationView.setVisible(true) + duration = 300 + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + + if (smallLayout) { + binding.navigationView.translationY = + binding.navigationView.height.toFloat() * 2 + translationY(0f) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * -2 + translationX(0f) + } else { + binding.navigationView.translationX = + binding.navigationView.width.toFloat() * 2 + translationX(0f) + } + } + } else { + duration = 300 + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + + if (smallLayout) { + translationY(binding.navigationView.height.toFloat() * 2) + } else { + if (ViewCompat.getLayoutDirection(binding.navigationView) == + ViewCompat.LAYOUT_DIRECTION_LTR + ) { + translationX(binding.navigationView.width.toFloat() * -2) + } else { + translationX(binding.navigationView.width.toFloat() * 2) + } + } + } + }.withEndAction { + if (!visible) { + binding.navigationView.setVisible(visible = false, gone = false) + } + }.start() + } + + private fun showStatusBarShade(visible: Boolean) { + binding.statusBarShade.animate().apply { + if (visible) { + binding.statusBarShade.setVisible(true) + binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2 + duration = 300 + translationY(0f) + interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) + } else { + duration = 300 + translationY(binding.navigationView.height.toFloat() * -2) + interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) + } + }.withEndAction { + if (!visible) { + binding.statusBarShade.setVisible(visible = false, gone = false) + } + }.start() + } + + override fun onResume() { + ThemeHelper.setCorrectTheme(this) + super.onResume() + } + + private fun setInsets() = + ViewCompat.setOnApplyWindowInsetsListener( + binding.root + ) { _: View, windowInsets: WindowInsetsCompat -> + val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) + val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams + mlpStatusShade.height = insets.top + binding.statusBarShade.layoutParams = mlpStatusShade + + // The only situation where we care to have a nav bar shade is when it's at the bottom + // of the screen where scrolling list elements can go behind it. + val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams + mlpNavShade.height = insets.bottom + binding.navigationBarShade.layoutParams = mlpNavShade + + windowInsets + } + + override fun setTheme(resId: Int) { + super.setTheme(resId) + themeId = resId + } + + val getGamesDirectory = + registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> + if (result != null) { + processGamesDir(result) + } + } + + fun processGamesDir(result: Uri) { + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val uriString = result.toString() + val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } + if (folder != null) { + Toast.makeText( + applicationContext, + R.string.folder_already_added, + Toast.LENGTH_SHORT + ).show() + return + } + + AddGameFolderDialogFragment.newInstance(uriString) + .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) + } + + val getProdKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result != null) { + processKey(result) + } + } + + fun processKey(result: Uri): Boolean { + if (FileUtil.getExtension(result) != "keys") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_prod_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "prod.keys" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + homeViewModel.setCheckKeys(true) + gamesViewModel.reloadGames(true) + return true + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return false + } + } + return false + } + + val getFirmware = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } + + val firmwarePath = + File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") + val cacheFirmwareDir = File("${cacheDir.path}/registered/") + + ProgressDialogFragment.newInstance( + this, + R.string.firmware_installing + ) { progressCallback, _ -> + var messageToShow: Any + try { + FileUtil.unzipToInternalStorage( + result.toString(), + cacheFirmwareDir, + progressCallback + ) + val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 + val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 + messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { + MessageDialogFragment.newInstance( + this, + titleId = R.string.firmware_installed_failure, + descriptionId = R.string.firmware_installed_failure_description + ) + } else { + firmwarePath.deleteRecursively() + cacheFirmwareDir.copyRecursively(firmwarePath, true) + NativeLibrary.initializeSystem(true) + homeViewModel.setCheckKeys(true) + getString(R.string.save_file_imported_success) + } + } catch (e: Exception) { + Log.error("[MainActivity] Firmware install failed - ${e.message}") + messageToShow = getString(R.string.fatal_error) + } finally { + cacheFirmwareDir.deleteRecursively() + } + messageToShow + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val getAmiiboKey = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + if (FileUtil.getExtension(result) != "bin") { + MessageDialogFragment.newInstance( + this, + titleId = R.string.reading_keys_failure, + descriptionId = R.string.install_amiibo_keys_failure_extension_description + ).show(supportFragmentManager, MessageDialogFragment.TAG) + return@registerForActivityResult + } + + contentResolver.takePersistableUriPermission( + result, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + + val dstPath = DirectoryInitialization.userDirectory + "/keys/" + if (FileUtil.copyUriToInternalStorage( + result, + dstPath, + "key_retail.bin" + ) != null + ) { + if (NativeLibrary.reloadKeys()) { + Toast.makeText( + applicationContext, + R.string.install_keys_success, + Toast.LENGTH_SHORT + ).show() + } else { + MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_keys_error, + descriptionId = R.string.install_keys_failure_description, + helpLinkId = R.string.dumping_keys_quickstart_link + ).show(supportFragmentManager, MessageDialogFragment.TAG) + } + } + } + + val installGameUpdate = registerForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { documents: List -> + if (documents.isEmpty()) { + return@registerForActivityResult + } + + if (addonViewModel.game == null) { + installContent(documents) + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.verifying_content, + false + ) { _, _ -> + var updatesMatchProgram = true + for (document in documents) { + val valid = NativeLibrary.doesUpdateMatchProgram( + addonViewModel.game!!.programId, + document.toString() + ) + if (!valid) { + updatesMatchProgram = false + break + } + } + + if (updatesMatchProgram) { + homeViewModel.setContentToInstall(documents) + } else { + MessageDialogFragment.newInstance( + this@MainActivity, + titleId = R.string.content_install_notice, + descriptionId = R.string.content_install_notice_description, + positiveAction = { homeViewModel.setContentToInstall(documents) }, + negativeAction = {} + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + private fun installContent(documents: List) { + ProgressDialogFragment.newInstance( + this@MainActivity, + R.string.installing_game_content + ) { progressCallback, messageCallback -> + var installSuccess = 0 + var installOverwrite = 0 + var errorBaseGame = 0 + var error = 0 + documents.forEach { + messageCallback.invoke(FileUtil.getFilename(it)) + when ( + InstallResult.from( + NativeLibrary.installFileToNand( + it.toString(), + progressCallback + ) + ) + ) { + InstallResult.Success -> { + installSuccess += 1 + } + + InstallResult.Overwrite -> { + installOverwrite += 1 + } + + InstallResult.BaseInstallAttempted -> { + errorBaseGame += 1 + } + + InstallResult.Failure -> { + error += 1 + } + } + } + + addonViewModel.refreshAddons() + + val separator = System.getProperty("line.separator") ?: "\n" + val installResult = StringBuilder() + if (installSuccess > 0) { + installResult.append( + getString( + R.string.install_game_content_success_install, + installSuccess + ) + ) + installResult.append(separator) + } + if (installOverwrite > 0) { + installResult.append( + getString( + R.string.install_game_content_success_overwrite, + installOverwrite + ) + ) + installResult.append(separator) + } + val errorTotal: Int = errorBaseGame + error + if (errorTotal > 0) { + installResult.append(separator) + installResult.append( + getString( + R.string.install_game_content_failed_count, + errorTotal + ) + ) + installResult.append(separator) + if (errorBaseGame > 0) { + installResult.append(separator) + installResult.append( + getString(R.string.install_game_content_failure_base) + ) + installResult.append(separator) + } + if (error > 0) { + installResult.append( + getString(R.string.install_game_content_failure_description) + ) + installResult.append(separator) + } + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_failure, + descriptionString = installResult.toString().trim(), + helpLinkId = R.string.install_game_content_help_link + ) + } else { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.install_game_content_success, + descriptionString = installResult.toString().trim() + ) + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val exportUserData = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip") + ) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.exporting_user_data, + true + ) { progressCallback, _ -> + val zipResult = FileUtil.zipFromInternalStorage( + File(DirectoryInitialization.userDirectory!!), + DirectoryInitialization.userDirectory!!, + BufferedOutputStream(contentResolver.openOutputStream(result)), + progressCallback, + compression = false + ) + return@newInstance when (zipResult) { + TaskState.Completed -> getString(R.string.user_data_export_success) + TaskState.Failed -> R.string.export_failed + TaskState.Cancelled -> R.string.user_data_export_cancelled + } + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } + + val importUserData = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> + if (result == null) { + return@registerForActivityResult + } + + ProgressDialogFragment.newInstance( + this, + R.string.importing_user_data + ) { progressCallback, _ -> + val checkStream = + ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) + var isYuzuBackup = false + checkStream.use { stream -> + var ze: ZipEntry? = null + while (stream.nextEntry?.also { ze = it } != null) { + val itemName = ze!!.name.trim() + if (itemName == "/config/config.ini" || itemName == "config/config.ini") { + isYuzuBackup = true + return@use + } + } + } + if (!isYuzuBackup) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.invalid_yuzu_backup, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Clear existing user data + NativeConfig.unloadGlobalConfig() + File(DirectoryInitialization.userDirectory!!).deleteRecursively() + + // Copy archive to internal storage + try { + FileUtil.unzipToInternalStorage( + result.toString(), + File(DirectoryInitialization.userDirectory!!), + progressCallback + ) + } catch (e: Exception) { + return@newInstance MessageDialogFragment.newInstance( + this, + titleId = R.string.import_failed, + descriptionId = R.string.user_data_import_failed_description + ) + } + + // Reinitialize relevant data + NativeLibrary.initializeSystem(true) + NativeConfig.initializeGlobalConfig() + gamesViewModel.reloadGames(false) + driverViewModel.reloadDriverData() + + return@newInstance getString(R.string.user_data_import_success) + }.show(supportFragmentManager, ProgressDialogFragment.TAG) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/ThemeProvider.kt new file mode 100644 index 000000000..511a6e4fa --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/ui/main/ThemeProvider.kt @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.ui.main + +interface ThemeProvider { + /** + * Provides theme ID by overriding an activity's 'setTheme' method and returning that result + */ + var themeId: Int +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/AddonUtil.kt new file mode 100644 index 000000000..8cc5ea71f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/AddonUtil.kt @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +object AddonUtil { + val validAddonDirectories = listOf("cheats", "exefs", "romfs") +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DirectoryInitialization.kt new file mode 100644 index 000000000..de0794a17 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DirectoryInitialization.kt @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import androidx.preference.PreferenceManager +import java.io.IOException +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.features.settings.model.Settings +import org.yuzu.yuzu_emu.overlay.model.OverlayControlData +import org.yuzu.yuzu_emu.overlay.model.OverlayControl +import org.yuzu.yuzu_emu.overlay.model.OverlayLayout +import org.yuzu.yuzu_emu.utils.PreferenceUtil.migratePreference + +object DirectoryInitialization { + private var userPath: String? = null + + var areDirectoriesReady: Boolean = false + + fun start() { + if (!areDirectoriesReady) { + initializeInternalStorage() + NativeLibrary.initializeSystem(false) + NativeConfig.initializeGlobalConfig() + migrateSettings() + areDirectoriesReady = true + } + } + + val userDirectory: String? + get() { + check(areDirectoriesReady) { "Directory initialization is not ready!" } + return userPath + } + + private fun initializeInternalStorage() { + try { + userPath = YuzuApplication.appContext.getExternalFilesDir(null)!!.canonicalPath + NativeLibrary.setAppDirectory(userPath!!) + } catch (e: IOException) { + e.printStackTrace() + } + } + + private fun migrateSettings() { + val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) + var saveConfig = false + val theme = preferences.migratePreference(Settings.PREF_THEME) + if (theme != null) { + IntSetting.THEME.setInt(theme) + saveConfig = true + } + + val themeMode = preferences.migratePreference(Settings.PREF_THEME_MODE) + if (themeMode != null) { + IntSetting.THEME_MODE.setInt(themeMode) + saveConfig = true + } + + val blackBackgrounds = + preferences.migratePreference(Settings.PREF_BLACK_BACKGROUNDS) + if (blackBackgrounds != null) { + BooleanSetting.BLACK_BACKGROUNDS.setBoolean(blackBackgrounds) + saveConfig = true + } + + val joystickRelCenter = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER) + if (joystickRelCenter != null) { + BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(joystickRelCenter) + saveConfig = true + } + + val dpadSlide = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE) + if (dpadSlide != null) { + BooleanSetting.DPAD_SLIDE.setBoolean(dpadSlide) + saveConfig = true + } + + val hapticFeedback = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_HAPTICS) + if (hapticFeedback != null) { + BooleanSetting.HAPTIC_FEEDBACK.setBoolean(hapticFeedback) + saveConfig = true + } + + val showPerformanceOverlay = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_FPS) + if (showPerformanceOverlay != null) { + BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(showPerformanceOverlay) + saveConfig = true + } + + val showInputOverlay = + preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY) + if (showInputOverlay != null) { + BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(showInputOverlay) + saveConfig = true + } + + val overlayOpacity = preferences.migratePreference(Settings.PREF_CONTROL_OPACITY) + if (overlayOpacity != null) { + IntSetting.OVERLAY_OPACITY.setInt(overlayOpacity) + saveConfig = true + } + + val overlayScale = preferences.migratePreference(Settings.PREF_CONTROL_SCALE) + if (overlayScale != null) { + IntSetting.OVERLAY_SCALE.setInt(overlayScale) + saveConfig = true + } + + var setOverlayData = false + val overlayControlData = NativeConfig.getOverlayControlData() + if (overlayControlData.isEmpty()) { + val overlayControlDataMap = + NativeConfig.getOverlayControlData().associateBy { it.id }.toMutableMap() + for (button in Settings.overlayPreferences) { + val buttonId = convertButtonId(button) + var buttonEnabled = preferences.migratePreference(button) + if (buttonEnabled == null) { + buttonEnabled = OverlayControl.map[buttonId]?.defaultVisibility == true + } + + var landscapeXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_LANDSCAPE_SUFFIX}" + )?.toDouble() + var landscapeYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_LANDSCAPE_SUFFIX}" + )?.toDouble() + if (landscapeXPosition == null || landscapeYPosition == null) { + val landscapePosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Landscape) ?: Pair(0.0, 0.0) + landscapeXPosition = landscapePosition.first + landscapeYPosition = landscapePosition.second + } + + var portraitXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_PORTRAIT_SUFFIX}" + )?.toDouble() + var portraitYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_PORTRAIT_SUFFIX}" + )?.toDouble() + if (portraitXPosition == null || portraitYPosition == null) { + val portraitPosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Portrait) ?: Pair(0.0, 0.0) + portraitXPosition = portraitPosition.first + portraitYPosition = portraitPosition.second + } + + var foldableXPosition = preferences.migratePreference( + "$button-X${Settings.PREF_FOLDABLE_SUFFIX}" + )?.toDouble() + var foldableYPosition = preferences.migratePreference( + "$button-Y${Settings.PREF_FOLDABLE_SUFFIX}" + )?.toDouble() + if (foldableXPosition == null || foldableYPosition == null) { + val foldablePosition = OverlayControl.map[buttonId] + ?.getDefaultPositionForLayout(OverlayLayout.Foldable) ?: Pair(0.0, 0.0) + foldableXPosition = foldablePosition.first + foldableYPosition = foldablePosition.second + } + + val controlData = OverlayControlData( + buttonId, + buttonEnabled, + Pair(landscapeXPosition, landscapeYPosition), + Pair(portraitXPosition, portraitYPosition), + Pair(foldableXPosition, foldableYPosition) + ) + overlayControlDataMap[buttonId] = controlData + setOverlayData = true + } + + if (setOverlayData) { + NativeConfig.setOverlayControlData( + overlayControlDataMap.map { it.value }.toTypedArray() + ) + saveConfig = true + } + } + + if (saveConfig) { + NativeConfig.saveGlobalConfig() + } + } + + private fun convertButtonId(buttonId: String): String = + when (buttonId) { + Settings.PREF_BUTTON_A -> OverlayControl.BUTTON_A.id + Settings.PREF_BUTTON_B -> OverlayControl.BUTTON_B.id + Settings.PREF_BUTTON_X -> OverlayControl.BUTTON_X.id + Settings.PREF_BUTTON_Y -> OverlayControl.BUTTON_Y.id + Settings.PREF_BUTTON_L -> OverlayControl.BUTTON_L.id + Settings.PREF_BUTTON_R -> OverlayControl.BUTTON_R.id + Settings.PREF_BUTTON_ZL -> OverlayControl.BUTTON_ZL.id + Settings.PREF_BUTTON_ZR -> OverlayControl.BUTTON_ZR.id + Settings.PREF_BUTTON_PLUS -> OverlayControl.BUTTON_PLUS.id + Settings.PREF_BUTTON_MINUS -> OverlayControl.BUTTON_MINUS.id + Settings.PREF_BUTTON_DPAD -> OverlayControl.COMBINED_DPAD.id + Settings.PREF_STICK_L -> OverlayControl.STICK_L.id + Settings.PREF_STICK_R -> OverlayControl.STICK_R.id + Settings.PREF_BUTTON_HOME -> OverlayControl.BUTTON_HOME.id + Settings.PREF_BUTTON_SCREENSHOT -> OverlayControl.BUTTON_CAPTURE.id + Settings.PREF_BUTTON_STICK_L -> OverlayControl.BUTTON_STICK_L.id + Settings.PREF_BUTTON_STICK_R -> OverlayControl.BUTTON_STICK_R.id + else -> "" + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DocumentsTree.kt new file mode 100644 index 000000000..738275297 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/DocumentsTree.kt @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import java.io.File +import java.util.* +import org.yuzu.yuzu_emu.model.MinimalDocumentFile + +class DocumentsTree { + private var root: DocumentsNode? = null + + fun setRoot(rootUri: Uri?) { + root = null + root = DocumentsNode() + root!!.uri = rootUri + root!!.isDirectory = true + } + + fun openContentUri(filepath: String, openMode: String?): Int { + val node = resolvePath(filepath) ?: return -1 + return FileUtil.openContentUri(node.uri.toString(), openMode) + } + + fun getFileSize(filepath: String): Long { + val node = resolvePath(filepath) + return if (node == null || node.isDirectory) { + 0 + } else { + FileUtil.getFileSize(node.uri.toString()) + } + } + + fun exists(filepath: String): Boolean { + return resolvePath(filepath) != null + } + + fun isDirectory(filepath: String): Boolean { + val node = resolvePath(filepath) + return node != null && node.isDirectory + } + + fun getParentDirectory(filepath: String): String { + val node = resolvePath(filepath)!! + val parentNode = node.parent + if (parentNode != null && parentNode.isDirectory) { + return parentNode.uri!!.toString() + } + return node.uri!!.toString() + } + + fun getFilename(filepath: String): String { + val node = resolvePath(filepath) + if (node != null) { + return node.name!! + } + return filepath + } + + private fun resolvePath(filepath: String): DocumentsNode? { + val tokens = StringTokenizer(filepath, File.separator, false) + var iterator = root + while (tokens.hasMoreTokens()) { + val token = tokens.nextToken() + if (token.isEmpty()) continue + iterator = find(iterator, token) + if (iterator == null) return null + } + return iterator + } + + private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? { + if (parent!!.isDirectory && !parent.loaded) { + structTree(parent) + } + return parent.children[filename] + } + + /** + * Construct current level directory tree + * @param parent parent node of this level + */ + private fun structTree(parent: DocumentsNode) { + val documents = FileUtil.listFiles(parent.uri!!) + for (document in documents) { + val node = DocumentsNode(document) + node.parent = parent + parent.children[node.name] = node + } + parent.loaded = true + } + + private class DocumentsNode { + var parent: DocumentsNode? = null + val children: MutableMap = HashMap() + var name: String? = null + var uri: Uri? = null + var loaded = false + var isDirectory = false + + constructor() + constructor(document: MinimalDocumentFile) { + name = document.filename + uri = document.uri + isDirectory = document.isDirectory + loaded = !isDirectory + } + + private constructor(document: DocumentFile, isCreateDir: Boolean) { + name = document.name + uri = document.uri + isDirectory = isCreateDir + loaded = true + } + + private fun rename(name: String) { + if (parent == null) { + return + } + parent!!.children.remove(this.name) + this.name = name + parent!!.children[name] = this + } + } + + companion object { + fun isNativePath(path: String): Boolean { + return if (path.isNotEmpty()) { + path[0] == '/' + } else { + false + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/FileUtil.kt new file mode 100644 index 000000000..fc2339f5a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/FileUtil.kt @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.documentfile.provider.DocumentFile +import java.io.BufferedInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URLDecoder +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.MinimalDocumentFile +import org.yuzu.yuzu_emu.model.TaskState +import java.io.BufferedOutputStream +import java.io.OutputStream +import java.lang.NullPointerException +import java.nio.charset.StandardCharsets +import java.util.zip.Deflater +import java.util.zip.ZipOutputStream +import kotlin.IllegalStateException + +object FileUtil { + const val PATH_TREE = "tree" + const val DECODE_METHOD = "UTF-8" + const val APPLICATION_OCTET_STREAM = "application/octet-stream" + const val TEXT_PLAIN = "text/plain" + + private val context get() = YuzuApplication.appContext + + /** + * Create a file from directory with filename. + * @param context Application context + * @param directory parent path for file. + * @param filename file display name. + * @return boolean + */ + fun createFile(directory: String?, filename: String): DocumentFile? { + var decodedFilename = filename + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD) + var mimeType = APPLICATION_OCTET_STREAM + if (decodedFilename.endsWith(".txt")) { + mimeType = TEXT_PLAIN + } + val exists = parent.findFile(decodedFilename) + return exists ?: parent.createFile(mimeType, decodedFilename) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Create a directory from directory with filename. + * @param directory parent path for directory. + * @param directoryName directory display name. + * @return boolean + */ + fun createDir(directory: String?, directoryName: String?): DocumentFile? { + var decodedDirectoryName = directoryName + try { + val directoryUri = Uri.parse(directory) + val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null + decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD) + val isExist = parent.findFile(decodedDirectoryName) + return isExist ?: parent.createDirectory(decodedDirectoryName) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot create file, error: " + e.message) + } + return null + } + + /** + * Open content uri and return file descriptor to JNI. + * @param path Native content uri path + * @param openMode will be one of "r", "r", "rw", "wa", "rwa" + * @return file descriptor + */ + @JvmStatic + fun openContentUri(path: String, openMode: String?): Int { + try { + val uri = Uri.parse(path) + val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!) + if (parcelFileDescriptor == null) { + Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path") + return -1 + } + val fileDescriptor = parcelFileDescriptor.detachFd() + parcelFileDescriptor.close() + return fileDescriptor + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot open content uri, error: " + e.message) + } + return -1 + } + + /** + * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow + * This function will be faster than DocumentFile.listFiles + * @param uri Directory uri. + * @return CheapDocument lists. + */ + fun listFiles(uri: Uri): Array { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var c: Cursor? = null + val results: MutableList = ArrayList() + try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + c = resolver.query(childrenUri, columns, null, null, null) + while (c!!.moveToNext()) { + val documentId = c.getString(0) + val documentName = c.getString(1) + val documentMimeType = c.getString(2) + val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) + val document = MinimalDocumentFile(documentName, documentMimeType, documentUri) + results.add(document) + } + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list file error: " + e.message) + } finally { + closeQuietly(c) + } + return results.toTypedArray() + } + + /** + * Check whether given path exists. + * @param path Native content uri path + * @return bool + */ + fun exists(path: String?, suppressLog: Boolean = false): Boolean { + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + c = context.contentResolver.query(mUri, columns, null, null, null) + return c!!.count > 0 + } catch (e: Exception) { + if (!suppressLog) { + Log.info("[FileUtil] Cannot find file from given path, error: " + e.message) + } + } finally { + closeQuietly(c) + } + return false + } + + /** + * Check whether given path is a directory + * @param path content uri path + * @return bool + */ + fun isDirectory(path: String): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + var isDirectory = false + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + val mimeType = c.getString(0) + isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot list files, error: " + e.message) + } finally { + closeQuietly(c) + } + return isDirectory + } + + /** + * Get file display name from given path + * @param uri content uri + * @return String display name + */ + fun getFilename(uri: Uri): String { + val resolver = YuzuApplication.appContext.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ) + var filename = "" + var c: Cursor? = null + try { + c = resolver.query(uri, columns, null, null, null) + c!!.moveToNext() + filename = c.getString(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return filename + } + + fun getFilesName(path: String): Array { + val uri = Uri.parse(path) + val files: MutableList = ArrayList() + for (file in listFiles(uri)) { + files.add(file.filename) + } + return files.toTypedArray() + } + + /** + * Get file size from given path. + * @param path content uri path + * @return long file size + */ + @JvmStatic + fun getFileSize(path: String): Long { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_SIZE + ) + var size: Long = 0 + var c: Cursor? = null + try { + val mUri = Uri.parse(path) + c = resolver.query(mUri, columns, null, null, null) + c!!.moveToNext() + size = c.getLong(0) + } catch (e: Exception) { + Log.error("[FileUtil]: Cannot get file size, error: " + e.message) + } finally { + closeQuietly(c) + } + return size + } + + /** + * Creates an input stream with a given [Uri] and copies its data to the given path. This will + * overwrite any pre-existing files. + * + * @param sourceUri The [Uri] to copy data from + * @param destinationParentPath Destination directory + * @param destinationFilename Optionally renames the file once copied + */ + fun copyUriToInternalStorage( + sourceUri: Uri, + destinationParentPath: String, + destinationFilename: String = "" + ): File? = + try { + val fileName = + if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename" + val inputStream = context.contentResolver.openInputStream(sourceUri)!! + + val destinationFile = File("$destinationParentPath$fileName") + if (destinationFile.exists()) { + destinationFile.delete() + } + + destinationFile.outputStream().use { fos -> + inputStream.use { it.copyTo(fos) } + } + destinationFile + } catch (e: IOException) { + null + } catch (e: NullPointerException) { + null + } + + /** + * Extracts the given zip file into the given directory. + * @param path String representation of a [Uri] or a typical path delimited by '/' + * @param destDir Location to unzip the contents of [path] into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + */ + @Throws(SecurityException::class) + fun unzipToInternalStorage( + path: String, + destDir: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + var totalEntries = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var tempEntry = zis.nextEntry + while (tempEntry != null) { + tempEntry = zis.nextEntry + totalEntries++ + } + } + + var progress = 0L + ZipInputStream(getInputStream(path)).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + if (progressCallback.invoke(totalEntries, progress)) { + return@use + } + + val newFile = File(destDir, entry.name) + val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile + + if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { + throw SecurityException("Zip file attempted path traversal! ${entry.name}") + } + + if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { + throw IOException("Failed to create directory $destinationDirectory") + } + + if (!entry.isDirectory) { + newFile.outputStream().use { fos -> zis.copyTo(fos) } + } + entry = zis.nextEntry + progress++ + } + } + } + + /** + * Creates a zip file from a directory within internal storage + * @param inputFile File representation of the item that will be zipped + * @param rootDir Directory containing the inputFile + * @param outputStream Stream where the zip file will be output + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @param compression Disables compression if true + */ + fun zipFromInternalStorage( + inputFile: File, + rootDir: String, + outputStream: BufferedOutputStream, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }, + compression: Boolean = true + ): TaskState { + try { + ZipOutputStream(outputStream).use { zos -> + if (!compression) { + zos.setMethod(ZipOutputStream.DEFLATED) + zos.setLevel(Deflater.NO_COMPRESSION) + } + + var count = 0L + val totalFiles = inputFile.walkTopDown().count().toLong() + inputFile.walkTopDown().forEach { file -> + if (progressCallback.invoke(totalFiles, count)) { + return TaskState.Cancelled + } + + if (!file.isDirectory) { + val entryName = + file.absolutePath.removePrefix(rootDir).removePrefix("/") + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + if (file.isFile) { + file.inputStream().use { fis -> fis.copyTo(zos) } + } + count++ + } + } + } + } catch (e: Exception) { + Log.error("[FileUtil] Failed creating zip file - ${e.message}") + return TaskState.Failed + } + return TaskState.Completed + } + + /** + * Helper function that copies the contents of a DocumentFile folder into a [File] + * @param file [File] representation of the folder to copy into + * @param progressCallback Lambda that is called with the total number of files and the current + * progress through the process. Stops execution as soon as possible if this returns true. + * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa + */ + fun DocumentFile.copyFilesTo( + file: File, + progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } + ) { + file.mkdirs() + if (!this.isDirectory || !file.isDirectory) { + throw IllegalStateException( + "[FileUtil] Tried to copy a folder into a file or vice versa" + ) + } + + var count = 0L + val totalFiles = this.listFiles().size.toLong() + this.listFiles().forEach { + if (progressCallback.invoke(totalFiles, count)) { + return + } + + val newFile = File(file, it.name!!) + if (it.isDirectory) { + newFile.mkdirs() + DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile) + } else { + val inputStream = + YuzuApplication.appContext.contentResolver.openInputStream(it.uri) + BufferedInputStream(inputStream).use { bos -> + if (!newFile.exists()) { + newFile.createNewFile() + } + newFile.outputStream().use { os -> bos.copyTo(os) } + } + } + count++ + } + } + + fun isRootTreeUri(uri: Uri): Boolean { + val paths = uri.pathSegments + return paths.size == 2 && PATH_TREE == paths[0] + } + + fun closeQuietly(closeable: AutoCloseable?) { + if (closeable != null) { + try { + closeable.close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (ignored: Exception) { + } + } + } + + fun getExtension(uri: Uri): String { + val fileName = getFilename(uri) + return fileName.substring(fileName.lastIndexOf(".") + 1) + .lowercase() + } + + fun isTreeUriValid(uri: Uri): Boolean { + val resolver = context.contentResolver + val columns = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE + ) + return try { + val docId: String = if (isRootTreeUri(uri)) { + DocumentsContract.getTreeDocumentId(uri) + } else { + DocumentsContract.getDocumentId(uri) + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + resolver.query(childrenUri, columns, null, null, null) + true + } catch (_: Exception) { + false + } + } + + fun getInputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).inputStream() + } else { + File(path).inputStream() + } + + fun getOutputStream(path: String) = if (path.contains("content://")) { + Uri.parse(path).outputStream() + } else { + File(path).outputStream() + } + + @Throws(IOException::class) + fun getStringFromFile(file: File): String = + String(file.readBytes(), StandardCharsets.UTF_8) + + @Throws(IOException::class) + fun getStringFromInputStream(stream: InputStream): String = + String(stream.readBytes(), StandardCharsets.UTF_8) + + fun DocumentFile.inputStream(): InputStream = + YuzuApplication.appContext.contentResolver.openInputStream(uri)!! + + fun DocumentFile.outputStream(): OutputStream = + YuzuApplication.appContext.contentResolver.openOutputStream(uri)!! + + fun Uri.inputStream(): InputStream = + YuzuApplication.appContext.contentResolver.openInputStream(this)!! + + fun Uri.outputStream(): OutputStream = + YuzuApplication.appContext.contentResolver.openOutputStream(this)!! + + fun Uri.asDocumentFile(): DocumentFile? = + DocumentFile.fromSingleUri(YuzuApplication.appContext, this) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameHelper.kt new file mode 100644 index 000000000..579b600f1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameHelper.kt @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.SharedPreferences +import android.net.Uri +import androidx.preference.PreferenceManager +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.Game +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.model.MinimalDocumentFile + +object GameHelper { + private const val KEY_OLD_GAME_PATH = "game_path" + const val KEY_GAMES = "Games" + + private lateinit var preferences: SharedPreferences + + fun getGames(): List { + val games = mutableListOf() + val context = YuzuApplication.appContext + preferences = PreferenceManager.getDefaultSharedPreferences(context) + + val gameDirs = mutableListOf() + val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: "" + if (oldGamesDir.isNotEmpty()) { + gameDirs.add(GameDir(oldGamesDir, true)) + preferences.edit().remove(KEY_OLD_GAME_PATH).apply() + } + gameDirs.addAll(NativeConfig.getGameDirs()) + + // Ensure keys are loaded so that ROM metadata can be decrypted. + NativeLibrary.reloadKeys() + + // Reset metadata so we don't use stale information + GameMetadata.resetMetadata() + + // Remove previous filesystem provider information so we can get up to date version info + NativeLibrary.clearFilesystemProvider() + + val badDirs = mutableListOf() + gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> + val gameDirUri = Uri.parse(gameDir.uriString) + val isValid = FileUtil.isTreeUriValid(gameDirUri) + if (isValid) { + addGamesRecursive( + games, + FileUtil.listFiles(gameDirUri), + if (gameDir.deepScan) 3 else 1 + ) + } else { + badDirs.add(index) + } + } + + // Remove all game dirs with insufficient permissions from config + if (badDirs.isNotEmpty()) { + var offset = 0 + badDirs.forEach { + gameDirs.removeAt(it - offset) + offset++ + } + } + NativeConfig.setGameDirs(gameDirs.toTypedArray()) + + // Cache list of games found on disk + val serializedGames = mutableSetOf() + games.forEach { + serializedGames.add(Json.encodeToString(it)) + } + preferences.edit() + .remove(KEY_GAMES) + .putStringSet(KEY_GAMES, serializedGames) + .apply() + + return games.toList() + } + + private fun addGamesRecursive( + games: MutableList, + files: Array, + depth: Int + ) { + if (depth <= 0) { + return + } + + files.forEach { + if (it.isDirectory) { + addGamesRecursive( + games, + FileUtil.listFiles(it.uri), + depth - 1 + ) + } else { + if (Game.extensions.contains(FileUtil.getExtension(it.uri))) { + val game = getGame(it.uri, true) + if (game != null) { + games.add(game) + } + } + } + } + } + + fun getGame(uri: Uri, addedToLibrary: Boolean): Game? { + val filePath = uri.toString() + if (!GameMetadata.getIsValid(filePath)) { + return null + } + + // Needed to update installed content information + NativeLibrary.addFileToFilesystemProvider(filePath) + + var name = GameMetadata.getTitle(filePath) + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = FileUtil.getFilename(uri) + } + var programId = GameMetadata.getProgramId(filePath) + + // If the game's ID field is empty, use the filename without extension. + if (programId.isEmpty()) { + programId = name.substring(0, name.lastIndexOf(".")) + } + + val newGame = Game( + name, + filePath, + programId, + GameMetadata.getDeveloper(filePath), + GameMetadata.getVersion(filePath, false), + GameMetadata.getIsHomebrew(filePath) + ) + + if (addedToLibrary) { + val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) + if (addedTime == 0L) { + preferences.edit() + .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) + .apply() + } + } + + return newGame + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameIconUtils.kt new file mode 100644 index 000000000..d05020560 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameIconUtils.kt @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.LayerDrawable +import android.widget.ImageView +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import androidx.lifecycle.LifecycleOwner +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 org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.model.Game + +class GameIconFetcher( + private val game: Game, + private val options: Options +) : Fetcher { + override suspend fun fetch(): FetchResult { + return DrawableResult( + drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources), + isSampled = false, + dataSource = DataSource.DISK + ) + } + + private fun decodeGameIcon(uri: String): Bitmap? { + val data = GameMetadata.getIcon(uri) + return BitmapFactory.decodeByteArray( + data, + 0, + data.size, + BitmapFactory.Options() + ) + } + + 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 { + private val imageLoader = ImageLoader.Builder(YuzuApplication.appContext) + .components { + add(GameIconKeyer()) + add(GameIconFetcher.Factory()) + } + .memoryCache { + MemoryCache.Builder(YuzuApplication.appContext) + .maxSizePercent(0.25) + .build() + } + .build() + + fun loadGameIcon(game: Game, imageView: ImageView) { + val request = ImageRequest.Builder(YuzuApplication.appContext) + .data(game) + .target(imageView) + .error(R.drawable.default_icon) + .build() + imageLoader.enqueue(request) + } + + suspend fun getGameIcon(lifecycleOwner: LifecycleOwner, game: Game): Bitmap { + val request = ImageRequest.Builder(YuzuApplication.appContext) + .data(game) + .lifecycle(lifecycleOwner) + .error(R.drawable.default_icon) + .build() + return imageLoader.execute(request) + .drawable!!.toBitmap(config = Bitmap.Config.ARGB_8888) + } + + suspend fun getShortcutIcon(lifecycleOwner: LifecycleOwner, game: Game): IconCompat { + val layerDrawable = ResourcesCompat.getDrawable( + YuzuApplication.appContext.resources, + R.drawable.shortcut, + null + ) as LayerDrawable + layerDrawable.setDrawableByLayerId( + R.id.shortcut_foreground, + getGameIcon(lifecycleOwner, game).toDrawable(YuzuApplication.appContext.resources) + ) + val inset = YuzuApplication.appContext.resources + .getDimensionPixelSize(R.dimen.icon_inset) + layerDrawable.setLayerInset(1, inset, inset, inset, inset) + return IconCompat.createWithAdaptiveBitmap( + layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888) + ) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameMetadata.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameMetadata.kt new file mode 100644 index 000000000..8e412482a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GameMetadata.kt @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +object GameMetadata { + external fun getIsValid(path: String): Boolean + + external fun getTitle(path: String): String + + external fun getProgramId(path: String): String + + external fun getDeveloper(path: String): String + + external fun getVersion(path: String, reload: Boolean): String + + external fun getIcon(path: String): ByteArray + + external fun getIsHomebrew(path: String): Boolean + + external fun resetMetadata() +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverHelper.kt new file mode 100644 index 000000000..a72dea8f1 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverHelper.kt @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.graphics.SurfaceTexture +import android.net.Uri +import android.os.Build +import android.view.Surface +import java.io.File +import java.io.IOException +import org.yuzu.yuzu_emu.NativeLibrary +import org.yuzu.yuzu_emu.YuzuApplication +import org.yuzu.yuzu_emu.features.settings.model.StringSetting +import java.io.FileNotFoundException +import java.util.zip.ZipException +import java.util.zip.ZipFile + +object GpuDriverHelper { + private const val META_JSON_FILENAME = "meta.json" + private var fileRedirectionPath: String? = null + var driverInstallationPath: String? = null + private var hookLibPath: String? = null + + val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/" + + fun initializeDriverParameters() { + try { + // Initialize the file redirection directory. + fileRedirectionPath = YuzuApplication.appContext + .getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" + + // Initialize the driver installation directory. + driverInstallationPath = YuzuApplication.appContext + .filesDir.canonicalPath + "/gpu_driver/" + } catch (e: IOException) { + throw RuntimeException(e) + } + + // Initialize directories. + initializeDirectories() + + // Initialize hook libraries directory. + hookLibPath = YuzuApplication.appContext.applicationInfo.nativeLibraryDir + "/" + + // Initialize GPU driver. + NativeLibrary.initializeGpuDriver( + hookLibPath, + driverInstallationPath, + installedCustomDriverData.libraryName, + fileRedirectionPath + ) + } + + fun getDrivers(): MutableList> { + val driverZips = File(driverStoragePath).listFiles() + val drivers: MutableList> = + driverZips + ?.mapNotNull { + val metadata = getMetadataFromZip(it) + metadata.name?.let { _ -> Pair(it.path, metadata) } + } + ?.sortedByDescending { it: Pair -> it.second.name } + ?.distinct() + ?.toMutableList() ?: mutableListOf() + return drivers + } + + fun installDefaultDriver() { + // Removing the installed driver will result in the backend using the default system driver. + File(driverInstallationPath!!).deleteRecursively() + initializeDriverParameters() + } + + fun copyDriverToInternalStorage(driverUri: Uri): Boolean { + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + return true + } + + /** + * Copies driver zip into user data directory so that it can be exported along with + * other user data and also unzipped into the installation directory + */ + fun installCustomDriver(driverUri: Uri): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Copy the zip file URI to user data + val copiedFile = + FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false + + // Validate driver + val metadata = getMetadataFromZip(copiedFile) + if (metadata.name == null) { + copiedFile.delete() + return false + } + + if (metadata.minApi > Build.VERSION.SDK_INT) { + copiedFile.delete() + return false + } + + // Unzip the driver. + try { + FileUtil.unzipToInternalStorage( + copiedFile.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Unzips driver into installation directory + */ + fun installCustomDriver(driver: File): Boolean { + // Revert to system default in the event the specified driver is bad. + installDefaultDriver() + + // Ensure we have directories. + initializeDirectories() + + // Validate driver + val metadata = getMetadataFromZip(driver) + if (metadata.name == null) { + driver.delete() + return false + } + + // Unzip the driver to the private installation directory + try { + FileUtil.unzipToInternalStorage( + driver.path, + File(driverInstallationPath!!) + ) + } catch (e: SecurityException) { + return false + } + + // Initialize the driver parameters. + initializeDriverParameters() + + return true + } + + /** + * Takes in a zip file and reads the meta.json file for presentation to the UI + * + * @param driver Zip containing driver and meta.json file + * @return A non-null [GpuDriverMetadata] instance that may have null members + */ + fun getMetadataFromZip(driver: File): GpuDriverMetadata { + try { + ZipFile(driver).use { zf -> + val entries = zf.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { + zf.getInputStream(entry).use { + return GpuDriverMetadata(it, entry.size) + } + } + } + } + } catch (_: ZipException) { + } catch (_: FileNotFoundException) { + } + return GpuDriverMetadata() + } + + external fun supportsCustomDriverLoading(): Boolean + + external fun getSystemDriverInfo( + surface: Surface = Surface(SurfaceTexture(true)), + hookLibPath: String = GpuDriverHelper.hookLibPath!! + ): Array? + + // Parse the custom driver metadata to retrieve the name. + val installedCustomDriverData: GpuDriverMetadata + get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) + + val customDriverSettingData: GpuDriverMetadata + get() = getMetadataFromZip(File(StringSetting.DRIVER_PATH.getString())) + + fun initializeDirectories() { + // Ensure the file redirection directory exists. + val fileRedirectionDir = File(fileRedirectionPath!!) + if (!fileRedirectionDir.exists()) { + fileRedirectionDir.mkdirs() + } + // Ensure the driver installation directory exists. + val driverInstallationDir = File(driverInstallationPath!!) + if (!driverInstallationDir.exists()) { + driverInstallationDir.mkdirs() + } + // Ensure the driver storage directory exists + val driverStorageDirectory = File(driverStoragePath) + if (!driverStorageDirectory.exists()) { + driverStorageDirectory.mkdirs() + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverMetadata.kt new file mode 100644 index 000000000..511a4171a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/GpuDriverMetadata.kt @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_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)) + 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/citron/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/InputHandler.kt new file mode 100644 index 000000000..2c7356e6a --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/InputHandler.kt @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import org.yuzu.yuzu_emu.features.input.NativeInput +import org.yuzu.yuzu_emu.features.input.YuzuInputOverlayDevice +import org.yuzu.yuzu_emu.features.input.YuzuPhysicalDevice + +object InputHandler { + var androidControllers = mapOf() + var registeredControllers = mutableListOf() + + fun dispatchKeyEvent(event: KeyEvent): Boolean { + val action = when (event.action) { + KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED + KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED + else -> return false + } + + var controllerData = androidControllers[event.device.controllerNumber] + if (controllerData == null) { + updateControllerData() + controllerData = androidControllers[event.device.controllerNumber] ?: return false + } + + NativeInput.onGamePadButtonEvent( + controllerData.getGUID(), + controllerData.getPort(), + event.keyCode, + action + ) + return true + } + + fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + val controllerData = + androidControllers[event.device.controllerNumber] ?: return false + event.device.motionRanges.forEach { + NativeInput.onGamePadAxisEvent( + controllerData.getGUID(), + controllerData.getPort(), + it.axis, + event.getAxisValue(it.axis) + ) + } + return true + } + + fun getDevices(): Map { + val gameControllerDeviceIds = mutableMapOf() + val deviceIds = InputDevice.getDeviceIds() + var port = 0 + val inputSettings = NativeConfig.getInputSettings(true) + deviceIds.forEach { deviceId -> + InputDevice.getDevice(deviceId)?.apply { + // Verify that the device has gamepad buttons, control sticks, or both. + if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + ) { + if (!gameControllerDeviceIds.contains(controllerNumber)) { + gameControllerDeviceIds[controllerNumber] = YuzuPhysicalDevice( + this, + port, + inputSettings[port].useSystemVibrator + ) + } + port++ + } + } + } + return gameControllerDeviceIds + } + + fun updateControllerData() { + androidControllers = getDevices() + androidControllers.forEach { + NativeInput.registerController(it.value) + } + + // Register the input overlay on a dedicated port for all player 1 vibrations + NativeInput.registerController(YuzuInputOverlayDevice(androidControllers.isEmpty(), 100)) + registeredControllers.clear() + NativeInput.getInputDevices().forEach { + registeredControllers.add(ParamPackage(it)) + } + registeredControllers.sortBy { it.get("port", 0) } + } + + fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/InsetsHelper.kt new file mode 100644 index 000000000..595f0d284 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/InsetsHelper.kt @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.annotation.SuppressLint +import android.content.Context + +object InsetsHelper { + const val THREE_BUTTON_NAVIGATION = 0 + const val TWO_BUTTON_NAVIGATION = 1 + const val GESTURE_NAVIGATION = 2 + + @SuppressLint("DiscouragedApi") + fun getSystemGestureType(context: Context): Int { + val resources = context.resources + val resourceId = + resources.getIdentifier("config_navBarInteractionMode", "integer", "android") + return if (resourceId != 0) { + resources.getInteger(resourceId) + } else { + 0 + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/LifecycleUtils.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/LifecycleUtils.kt new file mode 100644 index 000000000..d5c19c681 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/LifecycleUtils.kt @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** + * Collects this [Flow] with a given [LifecycleOwner]. + * @param scope [LifecycleOwner] that this [Flow] will be collected with. + * @param repeatState When to repeat collection on this [Flow]. + * @param resetState Optional lambda to reset state of an underlying [MutableStateFlow] after + * [stateCollector] has been run. + * @param stateCollector Lambda that receives new state. + */ +inline fun Flow.collect( + scope: LifecycleOwner, + repeatState: Lifecycle.State = Lifecycle.State.CREATED, + crossinline resetState: () -> Unit = {}, + crossinline stateCollector: (state: T) -> Unit +) { + scope.apply { + lifecycleScope.launch { + repeatOnLifecycle(repeatState) { + this@collect.collect { + stateCollector(it) + resetState() + } + } + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/Log.kt new file mode 100644 index 000000000..aebe84b0f --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/Log.kt @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.os.Build + +object Log { + // Tracks whether we should share the old log or the current log + var gameLaunched = false + + external fun debug(message: String) + + external fun warning(message: String) + + external fun info(message: String) + + external fun error(message: String) + + external fun critical(message: String) + + fun logDeviceInfo() { + info("Device Manufacturer - ${Build.MANUFACTURER}") + info("Device Model - ${Build.MODEL}") + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { + info("SoC Manufacturer - ${Build.SOC_MANUFACTURER}") + info("SoC Model - ${Build.SOC_MODEL}") + } + info("Total System Memory - ${MemoryUtil.getDeviceRAM()}") + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/MemoryUtil.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/MemoryUtil.kt new file mode 100644 index 000000000..0b94c73e5 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/MemoryUtil.kt @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.YuzuApplication +import java.util.Locale +import kotlin.math.ceil + +object MemoryUtil { + private val context get() = YuzuApplication.appContext + + private val Float.hundredths: String + get() = String.format(Locale.ROOT, "%.2f", this) + + // Required total system memory + const val REQUIRED_MEMORY = 8 + + const val Kb: Float = 1024F + const val Mb = Kb * 1024 + const val Gb = Mb * 1024 + const val Tb = Gb * 1024 + const val Pb = Tb * 1024 + const val Eb = Pb * 1024 + + fun bytesToSizeUnit(size: Float, roundUp: Boolean = false): String = + when { + size < Kb -> { + context.getString( + R.string.memory_formatted, + size.hundredths, + context.getString(R.string.memory_byte_shorthand) + ) + } + size < Mb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Kb) else (size / Kb).hundredths, + context.getString(R.string.memory_kilobyte) + ) + } + size < Gb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Mb) else (size / Mb).hundredths, + context.getString(R.string.memory_megabyte) + ) + } + size < Tb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Gb) else (size / Gb).hundredths, + context.getString(R.string.memory_gigabyte) + ) + } + size < Pb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Tb) else (size / Tb).hundredths, + context.getString(R.string.memory_terabyte) + ) + } + size < Eb -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Pb) else (size / Pb).hundredths, + context.getString(R.string.memory_petabyte) + ) + } + else -> { + context.getString( + R.string.memory_formatted, + if (roundUp) ceil(size / Eb) else (size / Eb).hundredths, + context.getString(R.string.memory_exabyte) + ) + } + } + + val totalMemory: Float + get() { + val memInfo = ActivityManager.MemoryInfo() + with(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) { + getMemoryInfo(memInfo) + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + memInfo.advertisedMem.toFloat() + } else { + memInfo.totalMem.toFloat() + } + } + + fun isLessThan(minimum: Int, size: Float): Boolean = + when (size) { + Kb -> totalMemory < Mb && totalMemory < minimum + Mb -> totalMemory < Gb && (totalMemory / Mb) < minimum + Gb -> totalMemory < Tb && (totalMemory / Gb) < minimum + Tb -> totalMemory < Pb && (totalMemory / Tb) < minimum + Pb -> totalMemory < Eb && (totalMemory / Pb) < minimum + Eb -> totalMemory / Eb < minimum + else -> totalMemory < Kb && totalMemory < minimum + } + + // Devices are unlikely to have 0.5GB increments of memory so we'll just round up to account for + // the potential error created by memInfo.totalMem + fun getDeviceRAM(): String = bytesToSizeUnit(totalMemory, true) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NativeConfig.kt new file mode 100644 index 000000000..7228f25d2 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NativeConfig.kt @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import org.yuzu.yuzu_emu.model.GameDir +import org.yuzu.yuzu_emu.overlay.model.OverlayControlData + +import org.yuzu.yuzu_emu.features.input.model.PlayerInput + +object NativeConfig { + /** + * Loads global config. + */ + @Synchronized + external fun initializeGlobalConfig() + + /** + * Destroys the stored global config object. This does not save the existing config. + */ + @Synchronized + external fun unloadGlobalConfig() + + /** + * Reads values in the global config file and saves them. + */ + @Synchronized + external fun reloadGlobalConfig() + + /** + * Saves global settings values in memory to disk. + */ + @Synchronized + external fun saveGlobalConfig() + + /** + * Creates per-game config for the specified parameters. Must be unloaded once per-game config + * is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets + * will follow the per-game config until the global config is reloaded. + * + * @param programId String representation of the u64 programId + * @param fileName Filename of the game, including its extension + */ + @Synchronized + external fun initializePerGameConfig(programId: String, fileName: String) + + @Synchronized + external fun isPerGameConfigLoaded(): Boolean + + /** + * Saves per-game settings values in memory to disk. + */ + @Synchronized + external fun savePerGameConfig() + + /** + * Destroys the stored per-game config object. This does not save the config. + */ + @Synchronized + external fun unloadPerGameConfig() + + @Synchronized + external fun getBoolean(key: String, needsGlobal: Boolean): Boolean + + @Synchronized + external fun setBoolean(key: String, value: Boolean) + + @Synchronized + external fun getByte(key: String, needsGlobal: Boolean): Byte + + @Synchronized + external fun setByte(key: String, value: Byte) + + @Synchronized + external fun getShort(key: String, needsGlobal: Boolean): Short + + @Synchronized + external fun setShort(key: String, value: Short) + + @Synchronized + external fun getInt(key: String, needsGlobal: Boolean): Int + + @Synchronized + external fun setInt(key: String, value: Int) + + @Synchronized + external fun getFloat(key: String, needsGlobal: Boolean): Float + + @Synchronized + external fun setFloat(key: String, value: Float) + + @Synchronized + external fun getLong(key: String, needsGlobal: Boolean): Long + + @Synchronized + external fun setLong(key: String, value: Long) + + @Synchronized + external fun getString(key: String, needsGlobal: Boolean): String + + @Synchronized + external fun setString(key: String, value: String) + + external fun getIsRuntimeModifiable(key: String): Boolean + + external fun getPairedSettingKey(key: String): String + + external fun getIsSwitchable(key: String): Boolean + + @Synchronized + external fun usingGlobal(key: String): Boolean + + @Synchronized + external fun setGlobal(key: String, global: Boolean) + + external fun getIsSaveable(key: String): Boolean + + external fun getDefaultToString(key: String): String + + /** + * Gets every [GameDir] in AndroidSettings::values.game_dirs + */ + @Synchronized + external fun getGameDirs(): Array + + /** + * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array + */ + @Synchronized + external fun setGameDirs(dirs: Array) + + /** + * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array + */ + @Synchronized + external fun addGameDir(dir: GameDir) + + /** + * Gets an array of the addons that are disabled for a given game + * + * @param programId String representation of a game's program ID + * @return An array of disabled addons + */ + @Synchronized + external fun getDisabledAddons(programId: String): Array + + /** + * Clears the disabled addons array corresponding to [programId] and replaces them + * with [disabledAddons] + * + * @param programId String representation of a game's program ID + * @param disabledAddons Replacement array of disabled addons + */ + @Synchronized + external fun setDisabledAddons(programId: String, disabledAddons: Array) + + /** + * Gets an array of [OverlayControlData] from settings + * + * @return An array of [OverlayControlData] + */ + @Synchronized + external fun getOverlayControlData(): Array + + /** + * Clears the AndroidSettings::values.overlay_control_data array and replaces its values + * with [overlayControlData] + * + * @param overlayControlData Replacement array of [OverlayControlData] + */ + @Synchronized + external fun setOverlayControlData(overlayControlData: Array) + + @Synchronized + external fun getInputSettings(global: Boolean): Array + + @Synchronized + external fun setInputSettings(value: Array, global: Boolean) + + /** + * Saves control values for a specific player + * Must be used when per game config is loaded + */ + @Synchronized + external fun saveControlPlayerValues() +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NfcReader.kt new file mode 100644 index 000000000..331b7ddca --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/NfcReader.kt @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.app.Activity +import android.app.PendingIntent +import android.content.Intent +import android.content.IntentFilter +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.nfc.tech.NfcA +import android.os.Build +import android.os.Handler +import android.os.Looper +import java.io.IOException +import org.yuzu.yuzu_emu.features.input.NativeInput + +class NfcReader(private val activity: Activity) { + private var nfcAdapter: NfcAdapter? = null + private var pendingIntent: PendingIntent? = null + + fun initialize() { + nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return + + pendingIntent = PendingIntent.getActivity( + activity, + 0, + Intent(activity, activity.javaClass), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + ) + + val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) + tagDetected.addCategory(Intent.CATEGORY_DEFAULT) + } + + fun startScanning() { + nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null) + } + + fun stopScanning() { + nfcAdapter?.disableForegroundDispatch(activity) + } + + fun onNewIntent(intent: Intent) { + val action = intent.action + if (NfcAdapter.ACTION_TAG_DISCOVERED != action && + NfcAdapter.ACTION_TECH_DISCOVERED != action && + NfcAdapter.ACTION_NDEF_DISCOVERED != action + ) { + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return + readTagData(tag) + return + } + + val tag = + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return + readTagData(tag) + } + + private fun readTagData(tag: Tag) { + if (!tag.techList.contains("android.nfc.tech.NfcA")) { + return + } + + val amiibo = NfcA.get(tag) ?: return + amiibo.connect() + + val tagData = ntag215ReadAll(amiibo) ?: return + NativeInput.onReadNfcTag(tagData) + + nfcAdapter?.ignore( + tag, + 1000, + { NativeInput.onRemoveNfcTag() }, + Handler(Looper.getMainLooper()) + ) + } + + private fun ntag215ReadAll(amiibo: NfcA): ByteArray? { + val bufferSize = amiibo.maxTransceiveLength + val tagSize = 0x21C + val pageSize = 4 + val lastPage = tagSize / pageSize - 1 + val tagData = ByteArray(tagSize) + + // We need to read the ntag in steps otherwise we overflow the buffer + for (i in 0..tagSize step bufferSize - 1) { + val dataStart = i / pageSize + var dataEnd = (i + bufferSize) / pageSize + + if (dataEnd > lastPage) { + dataEnd = lastPage + } + + try { + val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1) + System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize) + } catch (e: IOException) { + return null + } + } + return tagData + } + + private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x30.toByte(), + (page and 0xFF).toByte() + ) + ) + } + + private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x3A.toByte(), + (start and 0xFF).toByte(), + (end and 0xFF).toByte() + ) + ) + } + + private fun ntag215PWrite( + amiibo: NfcA, + page: Int, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0xA2.toByte(), + (page and 0xFF).toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } + + private fun ntag215PwdAuth( + amiibo: NfcA, + data1: Int, + data2: Int, + data3: Int, + data4: Int + ): ByteArray? { + return amiibo.transceive( + byteArrayOf( + 0x1B.toByte(), + (data1 and 0xFF).toByte(), + (data2 and 0xFF).toByte(), + (data3 and 0xFF).toByte(), + (data4 and 0xFF).toByte() + ) + ) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ParamPackage.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ParamPackage.kt new file mode 100644 index 000000000..83fc7da3c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ParamPackage.kt @@ -0,0 +1,141 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +// Kotlin version of src/common/param_package.h +class ParamPackage(serialized: String = "") { + private val KEY_VALUE_SEPARATOR = ":" + private val PARAM_SEPARATOR = "," + + private val ESCAPE_CHARACTER = "$" + private val KEY_VALUE_SEPARATOR_ESCAPE = "$0" + private val PARAM_SEPARATOR_ESCAPE = "$1" + private val ESCAPE_CHARACTER_ESCAPE = "$2" + + private val EMPTY_PLACEHOLDER = "[empty]" + + val data = mutableMapOf() + + init { + val pairs = serialized.split(PARAM_SEPARATOR) + for (pair in pairs) { + val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList() + if (keyValue.size != 2) { + Log.error("[ParamPackage] Invalid key pair $keyValue") + continue + } + + keyValue.forEachIndexed { i: Int, _: String -> + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR) + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER) + } + + set(keyValue[0], keyValue[1]) + } + } + + constructor(params: List>) : this() { + params.forEach { + data[it.first] = it.second + } + } + + fun serialize(): String { + if (data.isEmpty()) { + return EMPTY_PLACEHOLDER + } + + val result = StringBuilder() + data.forEach { + val keyValue = mutableListOf(it.key, it.value) + keyValue.forEachIndexed { i, _ -> + keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE) + keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE) + keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE) + } + result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR") + } + return result.removeSuffix(PARAM_SEPARATOR).toString() + } + + fun get(key: String, defaultValue: String): String = + if (has(key)) { + data[key]!! + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Int): Int = + if (has(key)) { + try { + data[key]!!.toInt() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + private fun Int.toBoolean(): Boolean = + if (this == 1) { + true + } else if (this == 0) { + false + } else { + throw Exception("Tried to convert a value to a boolean that was not 0 or 1!") + } + + fun get(key: String, defaultValue: Boolean): Boolean = + if (has(key)) { + try { + get(key, if (defaultValue) 1 else 0).toBoolean() + } catch (e: Exception) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun get(key: String, defaultValue: Float): Float = + if (has(key)) { + try { + data[key]!!.toFloat() + } catch (e: NumberFormatException) { + Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float") + defaultValue + } + } else { + Log.debug("[ParamPackage] key $key not found") + defaultValue + } + + fun set(key: String, value: String) { + data[key] = value + } + + fun set(key: String, value: Int) { + data[key] = value.toString() + } + + fun Boolean.toInt(): Int = if (this) 1 else 0 + fun set(key: String, value: Boolean) { + data[key] = value.toInt().toString() + } + + fun set(key: String, value: Float) { + data[key] = value.toString() + } + + fun has(key: String): Boolean = data.containsKey(key) + + fun erase(key: String) = data.remove(key) + + fun clear() = data.clear() +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/PreferenceUtil.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/PreferenceUtil.kt new file mode 100644 index 000000000..a233ba25c --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/PreferenceUtil.kt @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.SharedPreferences + +object PreferenceUtil { + /** + * Retrieves a shared preference value and then deletes the value in storage. + * @param key Associated key for the value in this preferences instance + * @return Typed value associated with [key]. Null if no such key exists. + */ + inline fun SharedPreferences.migratePreference(key: String): T? { + if (!this.contains(key)) { + return null + } + + val value: Any = when (T::class) { + String::class -> this.getString(key, "")!! + + Boolean::class -> this.getBoolean(key, false) + + Int::class -> this.getInt(key, 0) + + Float::class -> this.getFloat(key, 0f) + + Long::class -> this.getLong(key, 0) + + else -> throw IllegalStateException("Tried to migrate preference with invalid type!") + } + deletePreference(key) + return value as T + } + + fun SharedPreferences.deletePreference(key: String) = this.edit().remove(key).apply() +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/SerializableHelper.kt new file mode 100644 index 000000000..00e58faec --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/SerializableHelper.kt @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import java.io.Serializable + +object SerializableHelper { + inline fun Bundle.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializable(key, T::class.java) + } else { + getSerializable(key) as? T + } + } + + inline fun Intent.serializable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(key, T::class.java) + } else { + getSerializableExtra(key) as? T + } + } + + inline fun Bundle.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(key, T::class.java) + } else { + getParcelable(key) as? T + } + } + + inline fun Intent.parcelable(key: String): T? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(key, T::class.java) + } else { + getParcelableExtra(key) as? T + } + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ThemeHelper.kt new file mode 100644 index 000000000..6f7f40e43 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ThemeHelper.kt @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.content.res.Configuration +import android.graphics.Color +import android.os.Build +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 kotlin.math.roundToInt +import org.yuzu.yuzu_emu.R +import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting +import org.yuzu.yuzu_emu.features.settings.model.IntSetting +import org.yuzu.yuzu_emu.ui.main.ThemeProvider + +object ThemeHelper { + const val SYSTEM_BAR_ALPHA = 0.9f + + fun setTheme(activity: AppCompatActivity) { + setThemeMode(activity) + when (Theme.from(IntSetting.THEME.getInt())) { + Theme.Default -> activity.setTheme(R.style.Theme_Yuzu_Main) + Theme.MaterialYou -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou) + } else { + activity.setTheme(R.style.Theme_Yuzu_Main) + } + } + } + + // Using a specific night mode check because this could apply incorrectly when using the + // light app mode, dark system mode, and black backgrounds. Launching the settings activity + // will then show light mode colors/navigation bars but with black backgrounds. + if (BooleanSetting.BLACK_BACKGROUNDS.getBoolean() && isNightMode(activity)) { + activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark) + } + } + + @ColorInt + fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int { + return Color.argb( + (alphaFactor * Color.alpha(color)).roundToInt(), + Color.red(color), + Color.green(color), + Color.blue(color) + ) + } + + fun setCorrectTheme(activity: AppCompatActivity) { + val currentTheme = (activity as ThemeProvider).themeId + setTheme(activity) + if (currentTheme != (activity as ThemeProvider).themeId) { + activity.recreate() + } + } + + fun setThemeMode(activity: AppCompatActivity) { + val themeMode = IntSetting.THEME_MODE.getInt() + 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 + } +} + +enum class Theme(val int: Int) { + Default(0), + MaterialYou(1); + + companion object { + fun from(int: Int): Theme = entries.firstOrNull { it.int == int } ?: Default + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ViewUtils.kt new file mode 100644 index 000000000..244091aec --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/utils/ViewUtils.kt @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.utils + +import android.text.TextUtils +import android.view.View +import android.view.ViewGroup +import android.widget.TextView + +object ViewUtils { + fun showView(view: View, length: Long = 300) { + view.apply { + alpha = 0f + visibility = View.VISIBLE + isClickable = true + }.animate().apply { + duration = length + alpha(1f) + }.start() + } + + fun hideView(view: View, length: Long = 300) { + if (view.visibility == View.INVISIBLE) { + return + } + + view.apply { + alpha = 1f + isClickable = false + }.animate().apply { + duration = length + alpha(0f) + }.withEndAction { + view.visibility = View.INVISIBLE + }.start() + } + + fun View.updateMargins( + left: Int = -1, + top: Int = -1, + right: Int = -1, + bottom: Int = -1 + ) { + val layoutParams = this.layoutParams as ViewGroup.MarginLayoutParams + layoutParams.apply { + if (left != -1) { + leftMargin = left + } + if (top != -1) { + topMargin = top + } + if (right != -1) { + rightMargin = right + } + if (bottom != -1) { + bottomMargin = bottom + } + } + this.layoutParams = layoutParams + } + + /** + * Shows or hides a view. + * @param visible Whether a view will be made View.VISIBLE or View.INVISIBLE/GONE. + * @param gone Optional parameter for hiding a view. Uses View.GONE if true and View.INVISIBLE otherwise. + */ + fun View.setVisible(visible: Boolean, gone: Boolean = true) { + visibility = if (visible) { + View.VISIBLE + } else { + if (gone) { + View.GONE + } else { + View.INVISIBLE + } + } + } + + /** + * Starts a marquee on some text. + * @param delay Optional parameter for changing the start delay. 3 seconds of delay by default. + */ + fun TextView.marquee(delay: Long = 3000) { + ellipsize = null + marqueeRepeatLimit = -1 + isSingleLine = true + postDelayed({ + ellipsize = TextUtils.TruncateAt.MARQUEE + isSelected = true + }, delay) + } +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/viewholder/AbstractViewHolder.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/viewholder/AbstractViewHolder.kt new file mode 100644 index 000000000..7101ad434 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/viewholder/AbstractViewHolder.kt @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2024 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.viewholder + +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import org.yuzu.yuzu_emu.adapters.AbstractDiffAdapter +import org.yuzu.yuzu_emu.adapters.AbstractListAdapter + +/** + * [RecyclerView.ViewHolder] meant to work together with a [AbstractDiffAdapter] or a + * [AbstractListAdapter] so we can run [bind] on each list item without needing a manual hookup. + */ +abstract class AbstractViewHolder(binding: ViewBinding) : + RecyclerView.ViewHolder(binding.root) { + abstract fun bind(model: Model) +} diff --git a/src/android/app/src/main/java/org/citron/yuzu_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/citron/yuzu_emu/views/FixedRatioSurfaceView.kt new file mode 100644 index 000000000..2f0868c63 --- /dev/null +++ b/src/android/app/src/main/java/org/citron/yuzu_emu/views/FixedRatioSurfaceView.kt @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2023 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +package org.yuzu.yuzu_emu.views + +import android.content.Context +import android.util.AttributeSet +import android.util.Rational +import android.view.SurfaceView + +class FixedRatioSurfaceView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : SurfaceView(context, attrs, defStyleAttr) { + private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch + + /** + * Sets the desired aspect ratio for this view + * @param ratio the ratio to force the view to, or null to stretch to fit + */ + fun setAspectRatio(ratio: Rational?) { + aspectRatio = ratio?.toFloat() ?: 0f + requestLayout() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val displayWidth: Float = MeasureSpec.getSize(widthMeasureSpec).toFloat() + val displayHeight: Float = MeasureSpec.getSize(heightMeasureSpec).toFloat() + if (aspectRatio != 0f) { + val displayAspect = displayWidth / displayHeight + if (displayAspect < aspectRatio) { + // Max out width + val halfHeight = displayHeight / 2 + val surfaceHeight = displayWidth / aspectRatio + val newTop: Float = halfHeight - (surfaceHeight / 2) + val newBottom: Float = halfHeight + (surfaceHeight / 2) + super.onMeasure( + widthMeasureSpec, + MeasureSpec.makeMeasureSpec( + newBottom.toInt() - newTop.toInt(), + MeasureSpec.EXACTLY + ) + ) + return + } else { + // Max out height + val halfWidth = displayWidth / 2 + val surfaceWidth = displayHeight * aspectRatio + val newLeft: Float = halfWidth - (surfaceWidth / 2) + val newRight: Float = halfWidth + (surfaceWidth / 2) + super.onMeasure( + MeasureSpec.makeMeasureSpec( + newRight.toInt() - newLeft.toInt(), + MeasureSpec.EXACTLY + ), + heightMeasureSpec + ) + return + } + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } +} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt deleted file mode 100644 index 02a20dacf..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ /dev/null @@ -1,462 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu - -import android.content.DialogInterface -import android.net.Uri -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 com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.lang.ref.WeakReference -import org.yuzu.yuzu_emu.activities.EmulationActivity -import org.yuzu.yuzu_emu.fragments.CoreErrorDialogFragment -import org.yuzu.yuzu_emu.utils.DocumentsTree -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.Log -import org.yuzu.yuzu_emu.model.InstallResult -import org.yuzu.yuzu_emu.model.Patch -import org.yuzu.yuzu_emu.model.GameVerificationResult - -/** - * Class which contains methods that interact - * with the native side of the Yuzu code. - */ -object NativeLibrary { - @JvmField - var sEmulationActivity = WeakReference(null) - - init { - try { - System.loadLibrary("yuzu-android") - } catch (ex: UnsatisfiedLinkError) { - error("[NativeLibrary] $ex") - } - } - - @Keep - @JvmStatic - fun openContentUri(path: String?, openmode: String?): Int { - return if (DocumentsTree.isNativePath(path!!)) { - YuzuApplication.documentsTree!!.openContentUri(path, openmode) - } else { - FileUtil.openContentUri(path, openmode) - } - } - - @Keep - @JvmStatic - fun getSize(path: String?): Long { - return if (DocumentsTree.isNativePath(path!!)) { - YuzuApplication.documentsTree!!.getFileSize(path) - } else { - FileUtil.getFileSize(path) - } - } - - @Keep - @JvmStatic - fun exists(path: String?): Boolean { - return if (DocumentsTree.isNativePath(path!!)) { - YuzuApplication.documentsTree!!.exists(path) - } else { - FileUtil.exists(path, suppressLog = true) - } - } - - @Keep - @JvmStatic - fun isDirectory(path: String?): Boolean { - return if (DocumentsTree.isNativePath(path!!)) { - YuzuApplication.documentsTree!!.isDirectory(path) - } else { - FileUtil.isDirectory(path) - } - } - - @Keep - @JvmStatic - fun getParentDirectory(path: String): String = - if (DocumentsTree.isNativePath(path)) { - YuzuApplication.documentsTree!!.getParentDirectory(path) - } else { - path - } - - @Keep - @JvmStatic - fun getFilename(path: String): String = - if (DocumentsTree.isNativePath(path)) { - YuzuApplication.documentsTree!!.getFilename(path) - } else { - FileUtil.getFilename(Uri.parse(path)) - } - - external fun setAppDirectory(directory: String) - - /** - * Installs a nsp or xci file to nand - * @param filename String representation of file uri - * @return int representation of [InstallResult] - */ - external fun installFileToNand( - filename: String, - callback: (max: Long, progress: Long) -> Boolean - ): Int - - external fun doesUpdateMatchProgram(programId: String, updatePath: String): Boolean - - external fun initializeGpuDriver( - hookLibDir: String?, - customDriverDir: String?, - customDriverName: String?, - fileRedirectDir: String? - ) - - external fun reloadKeys(): Boolean - - external fun initializeSystem(reload: Boolean) - - /** - * Begins emulation. - */ - external fun run(path: String?, programIndex: Int, frontendInitiated: Boolean) - - // Surface Handling - external fun surfaceChanged(surf: Surface?) - - external fun surfaceDestroyed() - - /** - * Unpauses emulation from a paused state. - */ - external fun unpauseEmulation() - - /** - * Pauses emulation. - */ - external fun pauseEmulation() - - /** - * Stops emulation. - */ - external fun stopEmulation() - - /** - * Returns true if emulation is running (or is paused). - */ - external fun isRunning(): Boolean - - /** - * Returns true if emulation is paused. - */ - external fun isPaused(): Boolean - - /** - * Returns the performance stats for the current game - */ - external fun getPerfStats(): DoubleArray - - /** - * Returns the current CPU backend. - */ - external fun getCpuBackend(): String - - /** - * Returns the current GPU Driver. - */ - external fun getGpuDriver(): String - - external fun applySettings() - - external fun logSettings() - - enum class CoreError { - ErrorSystemFiles, - ErrorSavestate, - ErrorUnknown - } - - var coreErrorAlertResult = false - 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, "coreError") - } - - /** - * Handles a core error. - * - * @return true: continue; false: abort - */ - 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 { onCoreErrorImpl(title, message) } - - // Wait for the lock to notify that it is complete. - synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } - - return coreErrorAlertResult - } - - @Keep - @JvmStatic - fun exitEmulationActivity(resultCode: Int) { - val Success = 0 - val ErrorNotInitialized = 1 - val ErrorGetLoader = 2 - val ErrorSystemFiles = 3 - val ErrorSharedFont = 4 - val ErrorVideoCore = 5 - val ErrorUnknown = 6 - val ErrorLoader = 7 - - val captionId: Int - var descriptionId: Int - when (resultCode) { - ErrorVideoCore -> { - captionId = R.string.loader_error_video_core - descriptionId = R.string.loader_error_video_core_description - } - - else -> { - captionId = R.string.loader_error_encrypted - descriptionId = R.string.loader_error_encrypted_roms_description - if (!reloadKeys()) { - descriptionId = R.string.loader_error_encrypted_keys_description - } - } - } - - val emulationActivity = sEmulationActivity.get() - if (emulationActivity == null) { - Log.warning("[NativeLibrary] EmulationActivity is null, can't exit.") - return - } - - val builder = MaterialAlertDialogBuilder(emulationActivity) - .setTitle(captionId) - .setMessage( - Html.fromHtml( - emulationActivity.getString(descriptionId), - Html.FROM_HTML_MODE_LEGACY - ) - ) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - emulationActivity.finish() - } - .setOnDismissListener { emulationActivity.finish() } - emulationActivity.runOnUiThread { - val alert = builder.create() - alert.show() - (alert.findViewById(android.R.id.message) as TextView).movementMethod = - LinkMovementMethod.getInstance() - } - } - - fun setEmulationActivity(emulationActivity: EmulationActivity?) { - Log.debug("[NativeLibrary] Registering EmulationActivity.") - sEmulationActivity = WeakReference(emulationActivity) - } - - fun clearEmulationActivity() { - Log.debug("[NativeLibrary] Unregistering EmulationActivity.") - sEmulationActivity.clear() - } - - @Keep - @JvmStatic - fun onEmulationStarted() { - sEmulationActivity.get()!!.onEmulationStarted() - } - - @Keep - @JvmStatic - fun onEmulationStopped(status: Int) { - sEmulationActivity.get()!!.onEmulationStopped(status) - } - - @Keep - @JvmStatic - fun onProgramChanged(programIndex: Int) { - sEmulationActivity.get()!!.onProgramChanged(programIndex) - } - - /** - * Logs the Yuzu version, Android version and, CPU. - */ - external fun logDeviceInfo() - - /** - * Submits inline keyboard text. Called on input for buttons that result text. - * @param text Text to submit to the inline software keyboard implementation. - */ - external fun submitInlineKeyboardText(text: String?) - - /** - * Submits inline keyboard input. Used to indicate keys pressed that are not text. - * @param key_code Android Key Code associated with the keyboard input. - */ - external fun submitInlineKeyboardInput(key_code: Int) - - /** - * Creates a generic user directory if it doesn't exist already - */ - external fun initializeEmptyUserDirectory() - - /** - * Gets the launch path for a given applet. It is the caller's responsibility to also - * set the system's current applet ID before trying to launch the nca given by this function. - * - * @param id The applet entry ID - * @return The applet's launch path - */ - external fun getAppletLaunchPath(id: Long): String - - /** - * Sets the system's current applet ID before launching. - * - * @param appletId One of the ids in the Service::AM::Applets::AppletId enum - */ - external fun setCurrentAppletId(appletId: Int) - - /** - * Sets the cabinet mode for launching the cabinet applet. - * - * @param cabinetMode One of the modes that corresponds to the enum in Service::NFP::CabinetMode - */ - external fun setCabinetMode(cabinetMode: Int) - - /** - * Checks whether NAND contents are available and valid. - * - * @return 'true' if firmware is available - */ - external fun isFirmwareAvailable(): Boolean - - /** - * Checks the PatchManager for any addons that are available - * - * @param path Path to game file. Can be a [Uri]. - * @param programId String representation of a game's program ID - * @return Array of available patches - */ - external fun getPatchesForFile(path: String, programId: String): Array? - - /** - * Removes an update for a given [programId] - * @param programId String representation of a game's program ID - */ - external fun removeUpdate(programId: String) - - /** - * Removes all DLC for a [programId] - * @param programId String representation of a game's program ID - */ - external fun removeDLC(programId: String) - - /** - * Removes a mod installed for a given [programId] - * @param programId String representation of a game's program ID - * @param name The name of a mod as given by [getPatchesForFile]. This corresponds with the name - * of the mod's directory in a game's load folder. - */ - external fun removeMod(programId: String, name: String) - - /** - * Verifies all installed content - * @param callback UI callback for verification progress. Return true in the callback to cancel. - * @return Array of content that failed verification. Successful if empty. - */ - external fun verifyInstalledContents( - callback: (max: Long, progress: Long) -> Boolean - ): Array - - /** - * Verifies the contents of a game - * @param path String path to a game - * @param callback UI callback for verification progress. Return true in the callback to cancel. - * @return Int that is meant to be converted to a [GameVerificationResult] - */ - external fun verifyGameContents( - path: String, - callback: (max: Long, progress: Long) -> Boolean - ): Int - - /** - * Gets the save location for a specific game - * - * @param programId String representation of a game's program ID - * @return Save data path that may not exist yet - */ - external fun getSavePath(programId: String): String - - /** - * Gets the root save directory for the default profile as either - * /user/save/account/ or /user/save/000...000/ - * - * @param future If true, returns the /user/save/account/... directory - * @return Save data path that may not exist yet - */ - external fun getDefaultProfileSaveDataRoot(future: Boolean): String - - /** - * Adds a file to the manual filesystem provider in our EmulationSession instance - * @param path Path to the file we're adding. Can be a string representation of a [Uri] or - * a normal path - */ - external fun addFileToFilesystemProvider(path: String) - - /** - * Clears all files added to the manual filesystem provider in our EmulationSession instance - */ - external fun clearFilesystemProvider() - - /** - * Checks if all necessary keys are present for decryption - */ - external fun areKeysPresent(): Boolean -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt deleted file mode 100644 index 72943f33e..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu - -import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import org.yuzu.yuzu_emu.features.input.NativeInput -import java.io.File -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.DocumentsTree -import org.yuzu.yuzu_emu.utils.GpuDriverHelper -import org.yuzu.yuzu_emu.utils.Log - -fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir - -class YuzuApplication : Application() { - private fun createNotificationChannels() { - val noticeChannel = NotificationChannel( - getString(R.string.notice_notification_channel_id), - getString(R.string.notice_notification_channel_name), - NotificationManager.IMPORTANCE_HIGH - ) - noticeChannel.description = getString(R.string.notice_notification_channel_description) - noticeChannel.setSound(null, null) - - // Register the channel with the system; you can't change the importance - // or other notification behaviors after this - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(noticeChannel) - } - - override fun onCreate() { - super.onCreate() - application = this - documentsTree = DocumentsTree() - DirectoryInitialization.start() - GpuDriverHelper.initializeDriverParameters() - NativeInput.reloadInputDevices() - NativeLibrary.logDeviceInfo() - Log.logDeviceInfo() - - createNotificationChannels() - } - - companion object { - var documentsTree: DocumentsTree? = null - lateinit var application: YuzuApplication - - val appContext: Context - get() = application.applicationContext - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt deleted file mode 100644 index c962558a7..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ /dev/null @@ -1,509 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.activities - -import android.annotation.SuppressLint -import android.app.PendingIntent -import android.app.PictureInPictureParams -import android.app.RemoteAction -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.content.res.Configuration -import android.graphics.Rect -import android.graphics.drawable.Icon -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import android.os.Build -import android.os.Bundle -import android.util.Rational -import android.view.InputDevice -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.Surface -import android.view.View -import android.view.inputmethod.InputMethodManager -import android.widget.Toast -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.navigation.fragment.NavHostFragment -import androidx.preference.PreferenceManager -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.ActivityEmulationBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.model.EmulationViewModel -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.utils.InputHandler -import org.yuzu.yuzu_emu.utils.Log -import org.yuzu.yuzu_emu.utils.MemoryUtil -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.NfcReader -import org.yuzu.yuzu_emu.utils.ParamPackage -import org.yuzu.yuzu_emu.utils.ThemeHelper -import java.text.NumberFormat -import kotlin.math.roundToInt - -class EmulationActivity : AppCompatActivity(), SensorEventListener { - private lateinit var binding: ActivityEmulationBinding - - var isActivityRecreated = false - private lateinit var nfcReader: NfcReader - - private val gyro = FloatArray(3) - private val accel = FloatArray(3) - private var motionTimestamp: Long = 0 - private var flipMotionOrientation: Boolean = false - - private val actionPause = "ACTION_EMULATOR_PAUSE" - private val actionPlay = "ACTION_EMULATOR_PLAY" - private val actionMute = "ACTION_EMULATOR_MUTE" - private val actionUnmute = "ACTION_EMULATOR_UNMUTE" - - private val emulationViewModel: EmulationViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - Log.gameLaunched = true - ThemeHelper.setTheme(this) - - super.onCreate(savedInstanceState) - - InputHandler.updateControllerData() - val players = NativeConfig.getInputSettings(true) - var hasConfiguredControllers = false - players.forEach { - if (it.hasMapping()) { - hasConfiguredControllers = true - } - } - if (!hasConfiguredControllers && InputHandler.androidControllers.isNotEmpty()) { - var params: ParamPackage? = null - for (controller in InputHandler.registeredControllers) { - if (controller.get("port", -1) == 0) { - params = controller - break - } - } - - if (params != null) { - NativeInput.updateMappingsWithDefault( - 0, - params, - params.get("display", getString(R.string.unknown)) - ) - NativeConfig.saveGlobalConfig() - } - } - - binding = ActivityEmulationBinding.inflate(layoutInflater) - setContentView(binding.root) - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - navHostFragment.navController.setGraph(R.navigation.emulation_navigation, intent.extras) - - isActivityRecreated = savedInstanceState != null - - // Set these options now so that the SurfaceView the game renders into is the right size. - enableFullscreenImmersive() - - window.decorView.setBackgroundColor(getColor(android.R.color.black)) - - nfcReader = NfcReader(this) - nfcReader.initialize() - - val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) { - if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) { - Toast.makeText( - this, - getString( - R.string.device_memory_inadequate, - MemoryUtil.getDeviceRAM(), - getString( - R.string.memory_formatted, - NumberFormat.getInstance().format(MemoryUtil.REQUIRED_MEMORY), - getString(R.string.memory_gigabyte) - ) - ), - Toast.LENGTH_LONG - ).show() - preferences.edit() - .putBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, true) - .apply() - } - } - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - if (event.action == KeyEvent.ACTION_DOWN) { - if (keyCode == KeyEvent.KEYCODE_ENTER) { - // Special case, we do not support multiline input, dismiss the keyboard. - val overlayView: View = - this.findViewById(R.id.surface_input_overlay) - val im = - overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager - im.hideSoftInputFromWindow(overlayView.windowToken, 0) - } else { - val textChar = event.unicodeChar - if (textChar == 0) { - // No text, button input. - NativeLibrary.submitInlineKeyboardInput(keyCode) - } else { - // Text submitted. - NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString()) - } - } - } - return super.onKeyDown(keyCode, event) - } - - override fun onResume() { - super.onResume() - nfcReader.startScanning() - startMotionSensorListener() - InputHandler.updateControllerData() - - buildPictureInPictureParams() - } - - override fun onPause() { - super.onPause() - nfcReader.stopScanning() - stopMotionSensorListener() - } - - override fun onUserLeaveHint() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) { - val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() - .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() - enterPictureInPictureMode(pictureInPictureParamsBuilder.build()) - } - } - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - nfcReader.onNewIntent(intent) - InputHandler.updateControllerData() - } - - override fun dispatchKeyEvent(event: KeyEvent): Boolean { - if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && - event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD - ) { - return super.dispatchKeyEvent(event) - } - - if (emulationViewModel.drawerOpen.value) { - return super.dispatchKeyEvent(event) - } - - return InputHandler.dispatchKeyEvent(event) - } - - override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && - event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD - ) { - return super.dispatchGenericMotionEvent(event) - } - - if (emulationViewModel.drawerOpen.value) { - return super.dispatchGenericMotionEvent(event) - } - - // Don't attempt to do anything if we are disconnecting a device. - if (event.actionMasked == MotionEvent.ACTION_CANCEL) { - return true - } - - return InputHandler.dispatchGenericMotionEvent(event) - } - - override fun onSensorChanged(event: SensorEvent) { - val rotation = this.display?.rotation - if (rotation == Surface.ROTATION_90) { - flipMotionOrientation = true - } - if (rotation == Surface.ROTATION_270) { - flipMotionOrientation = false - } - - if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { - if (flipMotionOrientation) { - accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH - accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH - } else { - accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH - accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH - } - accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH - } - if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { - // Investigate why sensor value is off by 6x - if (flipMotionOrientation) { - gyro[0] = -event.values[1] / 6.0f - gyro[1] = event.values[0] / 6.0f - } else { - gyro[0] = event.values[1] / 6.0f - gyro[1] = -event.values[0] / 6.0f - } - gyro[2] = event.values[2] / 6.0f - } - - // Only update state on accelerometer data - if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { - return - } - val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 - motionTimestamp = event.timestamp - NativeInput.onDeviceMotionEvent( - NativeInput.Player1Device, - deltaTimestamp, - gyro[0], - gyro[1], - gyro[2], - accel[0], - accel[1], - accel[2] - ) - NativeInput.onDeviceMotionEvent( - NativeInput.ConsoleDevice, - deltaTimestamp, - gyro[0], - gyro[1], - gyro[2], - accel[0], - accel[1], - accel[2] - ) - } - - override fun onAccuracyChanged(sensor: Sensor, i: Int) {} - - private fun enableFullscreenImmersive() { - WindowCompat.setDecorFitsSystemWindows(window, false) - - WindowInsetsControllerCompat(window, window.decorView).let { controller -> - controller.hide(WindowInsetsCompat.Type.systemBars()) - controller.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - } - - private fun PictureInPictureParams.Builder.getPictureInPictureAspectBuilder(): - PictureInPictureParams.Builder { - val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { - 0 -> Rational(16, 9) - 1 -> Rational(4, 3) - 2 -> Rational(21, 9) - 3 -> Rational(16, 10) - else -> null // Best fit - } - return this.apply { aspectRatio?.let { setAspectRatio(it) } } - } - - private fun PictureInPictureParams.Builder.getPictureInPictureActionsBuilder(): - PictureInPictureParams.Builder { - val pictureInPictureActions: MutableList = mutableListOf() - val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - - if (NativeLibrary.isPaused()) { - val playIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_play) - val playPendingIntent = PendingIntent.getBroadcast( - this@EmulationActivity, - R.drawable.ic_pip_play, - Intent(actionPlay), - pendingFlags - ) - val playRemoteAction = RemoteAction( - playIcon, - getString(R.string.play), - getString(R.string.play), - playPendingIntent - ) - pictureInPictureActions.add(playRemoteAction) - } else { - val pauseIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_pause) - val pausePendingIntent = PendingIntent.getBroadcast( - this@EmulationActivity, - R.drawable.ic_pip_pause, - Intent(actionPause), - pendingFlags - ) - val pauseRemoteAction = RemoteAction( - pauseIcon, - getString(R.string.pause), - getString(R.string.pause), - pausePendingIntent - ) - pictureInPictureActions.add(pauseRemoteAction) - } - - if (BooleanSetting.AUDIO_MUTED.getBoolean()) { - val unmuteIcon = Icon.createWithResource( - this@EmulationActivity, - R.drawable.ic_pip_unmute - ) - val unmutePendingIntent = PendingIntent.getBroadcast( - this@EmulationActivity, - R.drawable.ic_pip_unmute, - Intent(actionUnmute), - pendingFlags - ) - val unmuteRemoteAction = RemoteAction( - unmuteIcon, - getString(R.string.unmute), - getString(R.string.unmute), - unmutePendingIntent - ) - pictureInPictureActions.add(unmuteRemoteAction) - } else { - val muteIcon = Icon.createWithResource(this@EmulationActivity, R.drawable.ic_pip_mute) - val mutePendingIntent = PendingIntent.getBroadcast( - this@EmulationActivity, - R.drawable.ic_pip_mute, - Intent(actionMute), - pendingFlags - ) - val muteRemoteAction = RemoteAction( - muteIcon, - getString(R.string.mute), - getString(R.string.mute), - mutePendingIntent - ) - pictureInPictureActions.add(muteRemoteAction) - } - - return this.apply { setActions(pictureInPictureActions) } - } - - fun buildPictureInPictureParams() { - val pictureInPictureParamsBuilder = PictureInPictureParams.Builder() - .getPictureInPictureActionsBuilder().getPictureInPictureAspectBuilder() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val isEmulationActive = emulationViewModel.emulationStarted.value && - !emulationViewModel.isEmulationStopping.value - pictureInPictureParamsBuilder.setAutoEnterEnabled( - BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && isEmulationActive - ) - } - setPictureInPictureParams(pictureInPictureParamsBuilder.build()) - } - - private var pictureInPictureReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent) { - if (intent.action == actionPlay) { - if (NativeLibrary.isPaused()) NativeLibrary.unpauseEmulation() - } else if (intent.action == actionPause) { - if (!NativeLibrary.isPaused()) NativeLibrary.pauseEmulation() - } - if (intent.action == actionUnmute) { - if (BooleanSetting.AUDIO_MUTED.getBoolean()) { - BooleanSetting.AUDIO_MUTED.setBoolean(false) - } - } else if (intent.action == actionMute) { - if (!BooleanSetting.AUDIO_MUTED.getBoolean()) { - BooleanSetting.AUDIO_MUTED.setBoolean(true) - } - } - buildPictureInPictureParams() - } - } - - @SuppressLint("UnspecifiedRegisterReceiverFlag") - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) - if (isInPictureInPictureMode) { - IntentFilter().apply { - addAction(actionPause) - addAction(actionPlay) - addAction(actionMute) - addAction(actionUnmute) - }.also { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(pictureInPictureReceiver, it, RECEIVER_EXPORTED) - } else { - registerReceiver(pictureInPictureReceiver, it) - } - } - } else { - try { - unregisterReceiver(pictureInPictureReceiver) - } catch (ignored: Exception) { - } - // Always resume audio, since there is no UI button - if (BooleanSetting.AUDIO_MUTED.getBoolean()) { - BooleanSetting.AUDIO_MUTED.setBoolean(false) - } - } - } - - fun onEmulationStarted() { - emulationViewModel.setEmulationStarted(true) - } - - fun onEmulationStopped(status: Int) { - if (status == 0 && emulationViewModel.programChanged.value == -1) { - finish() - } - emulationViewModel.setEmulationStopped(true) - } - - fun onProgramChanged(programIndex: Int) { - emulationViewModel.setProgramChanged(programIndex) - } - - private fun startMotionSensorListener() { - val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager - val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) - val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME) - sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME) - } - - private fun stopMotionSensorListener() { - val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager - val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) - val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) - - sensorManager.unregisterListener(this, gyroSensor) - sensorManager.unregisterListener(this, accelSensor) - } - - companion object { - const val EXTRA_SELECTED_GAME = "SelectedGame" - - fun launch(activity: AppCompatActivity, game: Game) { - val launcher = Intent(activity, EmulationActivity::class.java) - launcher.putExtra(EXTRA_SELECTED_GAME, game) - activity.startActivity(launcher) - } - - private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean { - if (view == null) { - return true - } - val viewBounds = Rect() - view.getGlobalVisibleRect(viewBounds) - return !viewBounds.contains(x.roundToInt(), y.roundToInt()) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt deleted file mode 100644 index 0ab1b46c3..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractDiffAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.annotation.SuppressLint -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder -import androidx.recyclerview.widget.RecyclerView - -/** - * Generic adapter that implements an [AsyncDifferConfig] and covers some of the basic boilerplate - * code used in every [RecyclerView]. - * Type assigned to [Model] must inherit from [Object] in order to be compared properly. - * @param exact Decides whether each item will be compared by reference or by their contents - */ -abstract class AbstractDiffAdapter>( - exact: Boolean = true -) : ListAdapter(AsyncDifferConfig.Builder(DiffCallback(exact)).build()) { - override fun onBindViewHolder(holder: Holder, position: Int) = - holder.bind(currentList[position]) - - private class DiffCallback(val exact: Boolean) : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { - if (exact) { - return oldItem === newItem - } - return oldItem == newItem - } - - @SuppressLint("DiffUtilEquals") - override fun areContentsTheSame(oldItem: Model & Any, newItem: Model & Any): Boolean { - return oldItem == newItem - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt deleted file mode 100644 index 3dfee3d0c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractListAdapter.kt +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.annotation.SuppressLint -import androidx.recyclerview.widget.RecyclerView -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -/** - * Generic list class meant to take care of basic lists - * @param currentList The list to show initially - */ -abstract class AbstractListAdapter>( - open var currentList: List -) : RecyclerView.Adapter() { - override fun onBindViewHolder(holder: Holder, position: Int) = - holder.bind(currentList[position]) - - override fun getItemCount(): Int = currentList.size - - /** - * Adds an item to [currentList] and notifies the underlying adapter of the change. If no parameter - * is passed in for position, [item] is added to the end of the list. Invokes [callback] last. - * @param item The item to add to the list - * @param position Index where [item] will be added - * @param callback Lambda that's called at the end of the list changes and has the added list - * position passed in as a parameter - */ - open fun addItem(item: Model, position: Int = -1, callback: ((position: Int) -> Unit)? = null) { - val newList = currentList.toMutableList() - val positionToUpdate: Int - if (position == -1) { - newList.add(item) - currentList = newList - positionToUpdate = currentList.size - 1 - } else { - newList.add(position, item) - currentList = newList - positionToUpdate = position - } - onItemAdded(positionToUpdate, callback) - } - - protected fun onItemAdded(position: Int, callback: ((Int) -> Unit)? = null) { - notifyItemInserted(position) - callback?.invoke(position) - } - - /** - * Replaces the [item] at [position] in the [currentList] and notifies the underlying adapter - * of the change. Invokes [callback] last. - * @param item New list item - * @param position Index where [item] will replace the existing list item - * @param callback Lambda that's called at the end of the list changes and has the changed list - * position passed in as a parameter - */ - fun changeItem(item: Model, position: Int, callback: ((position: Int) -> Unit)? = null) { - val newList = currentList.toMutableList() - newList[position] = item - currentList = newList - onItemChanged(position, callback) - } - - protected fun onItemChanged(position: Int, callback: ((Int) -> Unit)? = null) { - notifyItemChanged(position) - callback?.invoke(position) - } - - /** - * Removes the list item at [position] in [currentList] and notifies the underlying adapter - * of the change. Invokes [callback] last. - * @param position Index where the list item will be removed - * @param callback Lambda that's called at the end of the list changes and has the removed list - * position passed in as a parameter - */ - fun removeItem(position: Int, callback: ((position: Int) -> Unit)? = null) { - val newList = currentList.toMutableList() - newList.removeAt(position) - currentList = newList - onItemRemoved(position, callback) - } - - protected fun onItemRemoved(position: Int, callback: ((Int) -> Unit)? = null) { - notifyItemRemoved(position) - callback?.invoke(position) - } - - /** - * Replaces [currentList] with [newList] and notifies the underlying adapter of the change. - * @param newList The new list to replace [currentList] - */ - @SuppressLint("NotifyDataSetChanged") - open fun replaceList(newList: List) { - currentList = newList - notifyDataSetChanged() - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt deleted file mode 100644 index 52163f9d7..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AbstractSingleSelectionList.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import org.yuzu.yuzu_emu.model.SelectableItem -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -/** - * Generic list class meant to take care of single selection UI updates - * @param currentList The list to show initially - * @param defaultSelection The default selection to use if no list items are selected by - * [SelectableItem.selected] or if the currently selected item is removed from the list - */ -abstract class AbstractSingleSelectionList< - Model : SelectableItem, - Holder : AbstractViewHolder - >( - final override var currentList: List, - private val defaultSelection: DefaultSelection = DefaultSelection.Start -) : AbstractListAdapter(currentList) { - var selectedItem = getDefaultSelection() - - init { - findSelectedItem() - } - - /** - * Changes the selection state of the [SelectableItem] that was selected and the previously selected - * item and notifies the underlying adapter of the change for those items. Invokes [callback] last. - * Does nothing if [position] is the same as the currently selected item. - * @param position Index of the item that was selected - * @param callback Lambda that's called at the end of the list changes and has the selected list - * position passed in as a parameter - */ - fun selectItem(position: Int, callback: ((position: Int) -> Unit)? = null) { - if (position == selectedItem) { - return - } - - val previouslySelectedItem = selectedItem - selectedItem = position - if (currentList.indices.contains(selectedItem)) { - currentList[selectedItem].onSelectionStateChanged(true) - } - if (currentList.indices.contains(previouslySelectedItem)) { - currentList[previouslySelectedItem].onSelectionStateChanged(false) - } - onItemChanged(previouslySelectedItem) - onItemChanged(selectedItem) - callback?.invoke(position) - } - - /** - * Removes a given item from the list and notifies the underlying adapter of the change. If the - * currently selected item was the item that was removed, the item at the position provided - * by [defaultSelection] will be made the new selection. Invokes [callback] last. - * @param position Index of the item that was removed - * @param callback Lambda that's called at the end of the list changes and has the removed and - * selected list positions passed in as parameters - */ - fun removeSelectableItem( - position: Int, - callback: ((removedPosition: Int, selectedPosition: Int) -> Unit)? - ) { - removeItem(position) - if (position == selectedItem) { - selectedItem = getDefaultSelection() - currentList[selectedItem].onSelectionStateChanged(true) - onItemChanged(selectedItem) - } else if (position < selectedItem) { - selectedItem-- - } - callback?.invoke(position, selectedItem) - } - - override fun addItem(item: Model, position: Int, callback: ((Int) -> Unit)?) { - super.addItem(item, position, callback) - if (position <= selectedItem && position != -1) { - selectedItem++ - } - } - - override fun replaceList(newList: List) { - super.replaceList(newList) - findSelectedItem() - } - - private fun findSelectedItem() { - for (i in currentList.indices) { - if (currentList[i].selected) { - selectedItem = i - break - } - } - } - - private fun getDefaultSelection(): Int = - when (defaultSelection) { - DefaultSelection.Start -> currentList.indices.first - DefaultSelection.End -> currentList.indices.last - } - - enum class DefaultSelection { Start, End } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt deleted file mode 100644 index ff254d9b7..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AddonAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.yuzu.yuzu_emu.databinding.ListItemAddonBinding -import org.yuzu.yuzu_emu.model.Patch -import org.yuzu.yuzu_emu.model.AddonViewModel -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class AddonAdapter(val addonViewModel: AddonViewModel) : - AbstractDiffAdapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AddonViewHolder { - ListItemAddonBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return AddonViewHolder(it) } - } - - inner class AddonViewHolder(val binding: ListItemAddonBinding) : - AbstractViewHolder(binding) { - override fun bind(model: Patch) { - binding.root.setOnClickListener { - binding.addonCheckbox.isChecked = !binding.addonCheckbox.isChecked - } - binding.title.text = model.name - binding.version.text = model.version - binding.addonCheckbox.setOnCheckedChangeListener { _, checked -> - model.enabled = checked - } - binding.addonCheckbox.isChecked = model.enabled - binding.buttonDelete.setOnClickListener { - addonViewModel.setAddonToDelete(model) - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt deleted file mode 100644 index 41d7f72b8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/AppletAdapter.kt +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.res.ResourcesCompat -import androidx.fragment.app.FragmentActivity -import androidx.navigation.findNavController -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding -import org.yuzu.yuzu_emu.model.Applet -import org.yuzu.yuzu_emu.model.AppletInfo -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class AppletAdapter(val activity: FragmentActivity, applets: List) : - AbstractListAdapter(applets) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): AppletAdapter.AppletViewHolder { - CardSimpleOutlinedBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return AppletViewHolder(it) } - } - - inner class AppletViewHolder(val binding: CardSimpleOutlinedBinding) : - AbstractViewHolder(binding) { - override fun bind(model: Applet) { - binding.title.setText(model.titleId) - binding.description.setText(model.descriptionId) - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.context.resources, - model.iconId, - binding.icon.context.theme - ) - ) - - binding.root.setOnClickListener { onClick(model) } - } - - fun onClick(applet: Applet) { - val appletPath = NativeLibrary.getAppletLaunchPath(applet.appletInfo.entryId) - if (appletPath.isEmpty()) { - Toast.makeText( - binding.root.context, - R.string.applets_error_applet, - Toast.LENGTH_SHORT - ).show() - return - } - - if (applet.appletInfo == AppletInfo.Cabinet) { - binding.root.findNavController() - .navigate(R.id.action_appletLauncherFragment_to_cabinetLauncherDialogFragment) - return - } - - NativeLibrary.setCurrentAppletId(applet.appletInfo.appletId) - val appletGame = Game( - title = YuzuApplication.appContext.getString(applet.titleId), - path = appletPath - ) - val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) - binding.root.findNavController().navigate(action) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt deleted file mode 100644 index a56137148..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/CabinetLauncherDialogAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.res.ResourcesCompat -import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.DialogListItemBinding -import org.yuzu.yuzu_emu.model.CabinetMode -import org.yuzu.yuzu_emu.adapters.CabinetLauncherDialogAdapter.CabinetModeViewHolder -import org.yuzu.yuzu_emu.model.AppletInfo -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class CabinetLauncherDialogAdapter(val fragment: Fragment) : - AbstractListAdapter( - CabinetMode.values().copyOfRange(1, CabinetMode.entries.size).toList() - ) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CabinetModeViewHolder { - DialogListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return CabinetModeViewHolder(it) } - } - - inner class CabinetModeViewHolder(val binding: DialogListItemBinding) : - AbstractViewHolder(binding) { - override fun bind(model: CabinetMode) { - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.context.resources, - model.iconId, - binding.icon.context.theme - ) - ) - binding.title.setText(model.titleId) - - binding.root.setOnClickListener { onClick(model) } - } - - private fun onClick(mode: CabinetMode) { - val appletPath = NativeLibrary.getAppletLaunchPath(AppletInfo.Cabinet.entryId) - NativeLibrary.setCurrentAppletId(AppletInfo.Cabinet.appletId) - NativeLibrary.setCabinetMode(mode.id) - val appletGame = Game( - title = YuzuApplication.appContext.getString(R.string.cabinet_applet), - path = appletPath - ) - val action = HomeNavigationDirections.actionGlobalEmulationActivity(appletGame) - fragment.findNavController().navigate(action) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt deleted file mode 100644 index 50663ad91..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/DriverAdapter.kt +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.CardDriverOptionBinding -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import org.yuzu.yuzu_emu.model.Driver -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class DriverAdapter(private val driverViewModel: DriverViewModel) : - AbstractSingleSelectionList( - driverViewModel.driverList.value - ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DriverViewHolder { - CardDriverOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return DriverViewHolder(it) } - } - - inner class DriverViewHolder(val binding: CardDriverOptionBinding) : - AbstractViewHolder(binding) { - override fun bind(model: Driver) { - binding.apply { - radioButton.isChecked = model.selected - root.setOnClickListener { - selectItem(bindingAdapterPosition) { - driverViewModel.onDriverSelected(it) - driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) - } - } - buttonDelete.setOnClickListener { - removeSelectableItem( - bindingAdapterPosition - ) { removedPosition: Int, selectedPosition: Int -> - driverViewModel.onDriverRemoved(removedPosition, selectedPosition) - driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) - } - } - - // Delay marquee by 3s - title.marquee() - version.marquee() - description.marquee() - title.text = model.title - version.text = model.version - description.text = model.description - buttonDelete.setVisible( - model.title != binding.root.context.getString(R.string.system_gpu_driver) - ) - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt deleted file mode 100644 index 5cbd15d2a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/FolderAdapter.kt +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.net.Uri -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.fragment.app.FragmentActivity -import org.yuzu.yuzu_emu.databinding.CardFolderBinding -import org.yuzu.yuzu_emu.fragments.GameFolderPropertiesDialogFragment -import org.yuzu.yuzu_emu.model.GameDir -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class FolderAdapter(val activity: FragmentActivity, val gamesViewModel: GamesViewModel) : - AbstractDiffAdapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): FolderAdapter.FolderViewHolder { - CardFolderBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return FolderViewHolder(it) } - } - - inner class FolderViewHolder(val binding: CardFolderBinding) : - AbstractViewHolder(binding) { - override fun bind(model: GameDir) { - binding.apply { - path.text = Uri.parse(model.uriString).path - path.marquee() - - buttonEdit.setOnClickListener { - GameFolderPropertiesDialogFragment.newInstance(model) - .show( - activity.supportFragmentManager, - GameFolderPropertiesDialogFragment.TAG - ) - } - - buttonDelete.setOnClickListener { - gamesViewModel.removeFolder(model) - } - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt deleted file mode 100644 index b1f247ac3..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.net.Uri -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import androidx.navigation.findNavController -import androidx.preference.PreferenceManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.databinding.CardGameBinding -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.utils.GameIconUtils -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class GameAdapter(private val activity: AppCompatActivity) : - AbstractDiffAdapter(exact = false) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { - CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return GameViewHolder(it) } - } - - inner class GameViewHolder(val binding: CardGameBinding) : - AbstractViewHolder(binding) { - override fun bind(model: Game) { - binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP - GameIconUtils.loadGameIcon(model, binding.imageGameScreen) - - binding.textGameTitle.text = model.title.replace("[\\t\\n\\r]+".toRegex(), " ") - - binding.textGameTitle.marquee() - binding.cardGame.setOnClickListener { onClick(model) } - binding.cardGame.setOnLongClickListener { onLongClick(model) } - } - - fun onClick(game: Game) { - val gameExists = DocumentFile.fromSingleUri( - YuzuApplication.appContext, - Uri.parse(game.path) - )?.exists() == true - if (!gameExists) { - Toast.makeText( - YuzuApplication.appContext, - R.string.loader_error_file_not_found, - Toast.LENGTH_LONG - ).show() - - ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) - return - } - - val preferences = - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - preferences.edit() - .putLong( - game.keyLastPlayedTime, - System.currentTimeMillis() - ) - .apply() - - activity.lifecycleScope.launch { - withContext(Dispatchers.IO) { - val shortcut = - ShortcutInfoCompat.Builder(YuzuApplication.appContext, game.path) - .setShortLabel(game.title) - .setIcon(GameIconUtils.getShortcutIcon(activity, game)) - .setIntent(game.launchIntent) - .build() - ShortcutManagerCompat.pushDynamicShortcut(YuzuApplication.appContext, shortcut) - } - } - - val action = HomeNavigationDirections.actionGlobalEmulationActivity(game, true) - binding.root.findNavController().navigate(action) - } - - fun onLongClick(game: Game): Boolean { - val action = HomeNavigationDirections.actionGlobalPerGamePropertiesFragment(game) - binding.root.findNavController().navigate(action) - return true - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt deleted file mode 100644 index 7366e2c77..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GamePropertiesAdapter.kt +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.content.res.ResourcesCompat -import androidx.lifecycle.LifecycleOwner -import org.yuzu.yuzu_emu.databinding.CardInstallableIconBinding -import org.yuzu.yuzu_emu.databinding.CardSimpleOutlinedBinding -import org.yuzu.yuzu_emu.model.GameProperty -import org.yuzu.yuzu_emu.model.InstallableProperty -import org.yuzu.yuzu_emu.model.SubmenuProperty -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class GamePropertiesAdapter( - private val viewLifecycle: LifecycleOwner, - private var properties: List -) : AbstractListAdapter>(properties) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): AbstractViewHolder { - val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - PropertyType.Submenu.ordinal -> { - SubmenuPropertyViewHolder( - CardSimpleOutlinedBinding.inflate( - inflater, - parent, - false - ) - ) - } - - else -> InstallablePropertyViewHolder( - CardInstallableIconBinding.inflate( - inflater, - parent, - false - ) - ) - } - } - - override fun getItemViewType(position: Int): Int { - return when (properties[position]) { - is SubmenuProperty -> PropertyType.Submenu.ordinal - else -> PropertyType.Installable.ordinal - } - } - - inner class SubmenuPropertyViewHolder(val binding: CardSimpleOutlinedBinding) : - AbstractViewHolder(binding) { - override fun bind(model: GameProperty) { - val submenuProperty = model as SubmenuProperty - - binding.root.setOnClickListener { - submenuProperty.action.invoke() - } - - binding.title.setText(submenuProperty.titleId) - binding.description.setText(submenuProperty.descriptionId) - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.context.resources, - submenuProperty.iconId, - binding.icon.context.theme - ) - ) - - binding.details.marquee() - if (submenuProperty.details != null) { - binding.details.setVisible(true) - binding.details.text = submenuProperty.details.invoke() - } else if (submenuProperty.detailsFlow != null) { - binding.details.setVisible(true) - submenuProperty.detailsFlow.collect(viewLifecycle) { binding.details.text = it } - } else { - binding.details.setVisible(false) - } - } - } - - inner class InstallablePropertyViewHolder(val binding: CardInstallableIconBinding) : - AbstractViewHolder(binding) { - override fun bind(model: GameProperty) { - val installableProperty = model as InstallableProperty - - binding.title.setText(installableProperty.titleId) - binding.description.setText(installableProperty.descriptionId) - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.context.resources, - installableProperty.iconId, - binding.icon.context.theme - ) - ) - - binding.buttonInstall.setVisible(installableProperty.install != null) - binding.buttonInstall.setOnClickListener { installableProperty.install?.invoke() } - binding.buttonExport.setVisible(installableProperty.export != null) - binding.buttonExport.setOnClickListener { installableProperty.export?.invoke() } - } - } - - enum class PropertyType { - Submenu, - Installable - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt deleted file mode 100644 index 0bd196673..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.lifecycle.LifecycleOwner -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding -import org.yuzu.yuzu_emu.fragments.MessageDialogFragment -import org.yuzu.yuzu_emu.model.HomeSetting -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class HomeSettingAdapter( - private val activity: AppCompatActivity, - private val viewLifecycle: LifecycleOwner, - options: List -) : AbstractListAdapter(options) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { - CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return HomeOptionViewHolder(it) } - } - - inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : - AbstractViewHolder(binding) { - override fun bind(model: HomeSetting) { - binding.optionTitle.text = activity.resources.getString(model.titleId) - binding.optionDescription.text = activity.resources.getString(model.descriptionId) - binding.optionIcon.setImageDrawable( - ResourcesCompat.getDrawable( - activity.resources, - model.iconId, - activity.theme - ) - ) - - when (model.titleId) { - R.string.get_early_access -> - binding.optionLayout.background = - ContextCompat.getDrawable( - binding.optionCard.context, - R.drawable.premium_background - ) - } - - if (!model.isEnabled.invoke()) { - binding.optionTitle.alpha = 0.5f - binding.optionDescription.alpha = 0.5f - binding.optionIcon.alpha = 0.5f - } - - model.details.collect(viewLifecycle) { updateOptionDetails(it) } - binding.optionDetail.marquee() - - binding.root.setOnClickListener { onClick(model) } - } - - private fun onClick(model: HomeSetting) { - if (model.isEnabled.invoke()) { - model.onClick.invoke() - } else { - MessageDialogFragment.newInstance( - activity, - titleId = model.disabledTitleId, - descriptionId = model.disabledMessageId - ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) - } - } - - private fun updateOptionDetails(detailString: String) { - if (detailString.isNotEmpty()) { - binding.optionDetail.text = detailString - binding.optionDetail.setVisible(true) - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt deleted file mode 100644 index 1ba75fa2f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/InstallableAdapter.kt +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import org.yuzu.yuzu_emu.databinding.CardInstallableBinding -import org.yuzu.yuzu_emu.model.Installable -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class InstallableAdapter(installables: List) : - AbstractListAdapter(installables) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): InstallableAdapter.InstallableViewHolder { - CardInstallableBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return InstallableViewHolder(it) } - } - - inner class InstallableViewHolder(val binding: CardInstallableBinding) : - AbstractViewHolder(binding) { - override fun bind(model: Installable) { - binding.title.setText(model.titleId) - binding.description.setText(model.descriptionId) - - binding.buttonInstall.setVisible(model.install != null) - binding.buttonInstall.setOnClickListener { model.install?.invoke() } - binding.buttonExport.setVisible(model.export != null) - binding.buttonExport.setOnClickListener { model.export?.invoke() } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt deleted file mode 100644 index 1379968f9..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment -import org.yuzu.yuzu_emu.model.License -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class LicenseAdapter(private val activity: AppCompatActivity, licenses: List) : - AbstractListAdapter(licenses) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { - ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return LicenseViewHolder(it) } - } - - inner class LicenseViewHolder(val binding: ListItemSettingBinding) : - AbstractViewHolder(binding) { - override fun bind(model: License) { - binding.apply { - textSettingName.text = root.context.getString(model.titleId) - textSettingDescription.text = root.context.getString(model.descriptionId) - textSettingValue.setVisible(false) - - root.setOnClickListener { onClick(model) } - } - } - - private fun onClick(license: License) { - LicenseBottomSheetDialogFragment.newInstance(license) - .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt deleted file mode 100644 index a5f610b31..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.adapters - -import android.text.Html -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.res.ResourcesCompat -import androidx.lifecycle.ViewModelProvider -import com.google.android.material.button.MaterialButton -import org.yuzu.yuzu_emu.databinding.PageSetupBinding -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.SetupCallback -import org.yuzu.yuzu_emu.model.SetupPage -import org.yuzu.yuzu_emu.model.StepState -import org.yuzu.yuzu_emu.utils.ViewUtils -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder - -class SetupAdapter(val activity: AppCompatActivity, pages: List) : - AbstractListAdapter(pages) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { - PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return SetupPageViewHolder(it) } - } - - inner class SetupPageViewHolder(val binding: PageSetupBinding) : - AbstractViewHolder(binding), SetupCallback { - override fun bind(model: SetupPage) { - if (model.stepCompleted.invoke() == StepState.COMPLETE) { - binding.buttonAction.setVisible(visible = false, gone = false) - binding.textConfirmation.setVisible(true) - } - - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - activity.resources, - model.iconId, - activity.theme - ) - ) - binding.textTitle.text = activity.resources.getString(model.titleId) - binding.textDescription.text = - Html.fromHtml(activity.resources.getString(model.descriptionId), 0) - - binding.buttonAction.apply { - text = activity.resources.getString(model.buttonTextId) - if (model.buttonIconId != 0) { - icon = ResourcesCompat.getDrawable( - activity.resources, - model.buttonIconId, - activity.theme - ) - } - iconGravity = - if (model.leftAlignedIcon) { - MaterialButton.ICON_GRAVITY_START - } else { - MaterialButton.ICON_GRAVITY_END - } - setOnClickListener { - model.buttonAction.invoke(this@SetupPageViewHolder) - } - } - } - - override fun onStepCompleted() { - ViewUtils.hideView(binding.buttonAction, 200) - ViewUtils.showView(binding.textConfirmation, 200) - ViewModelProvider(activity)[HomeViewModel::class.java].setShouldPageForward(true) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt deleted file mode 100644 index e058067c9..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.applets.keyboard - -import android.content.Context -import android.os.Handler -import android.os.Looper -import android.view.KeyEvent -import android.view.View -import android.view.WindowInsets -import android.view.inputmethod.InputMethodManager -import androidx.annotation.Keep -import androidx.core.view.ViewCompat -import java.io.Serializable -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment - -@Keep -object SoftwareKeyboard { - lateinit var data: KeyboardData - val dataLock = Object() - - private fun executeNormalImpl(config: KeyboardConfig) { - val emulationActivity = NativeLibrary.sEmulationActivity.get() - data = KeyboardData(SwkbdResult.Cancel.ordinal, "") - val fragment = KeyboardDialogFragment.newInstance(config) - fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) - } - - private fun executeInlineImpl(config: KeyboardConfig) { - val emulationActivity = NativeLibrary.sEmulationActivity.get() - - val overlayView = emulationActivity!!.findViewById(R.id.surface_input_overlay) - val im = - overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) - - // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. - val handler = Handler(Looper.myLooper()!!) - val delayMs = 500 - handler.postDelayed( - object : Runnable { - override fun run() { - val insets = ViewCompat.getRootWindowInsets(overlayView) - val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) - if (isKeyboardVisible) { - handler.postDelayed(this, delayMs.toLong()) - return - } - - // No longer visible, submit the result. - NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) - } - }, - delayMs.toLong() - ) - } - - @JvmStatic - fun executeNormal(config: KeyboardConfig): KeyboardData { - NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } - synchronized(dataLock) { - dataLock.wait() - } - return data - } - - @JvmStatic - fun executeInline(config: KeyboardConfig) { - NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } - } - - // Corresponds to Service::AM::Applets::SwkbdType - enum class SwkbdType { - Normal, - NumberPad, - Qwerty, - Unknown3, - Latin, - SimplifiedChinese, - TraditionalChinese, - Korean - } - - // Corresponds to Service::AM::Applets::SwkbdPasswordMode - enum class SwkbdPasswordMode { - Disabled, - Enabled - } - - // Corresponds to Service::AM::Applets::SwkbdResult - enum class SwkbdResult { - Ok, - Cancel - } - - @Keep - data class KeyboardConfig( - var ok_text: String? = null, - var header_text: String? = null, - var sub_text: String? = null, - var guide_text: String? = null, - var initial_text: String? = null, - var left_optional_symbol_key: Short = 0, - var right_optional_symbol_key: Short = 0, - var max_text_length: Int = 0, - var min_text_length: Int = 0, - var initial_cursor_position: Int = 0, - var type: Int = 0, - var password_mode: Int = 0, - var text_draw_type: Int = 0, - var key_disable_flags: Int = 0, - var use_blur_background: Boolean = false, - var enable_backspace_button: Boolean = false, - var enable_return_button: Boolean = false, - var disable_cancel_button: Boolean = false - ) : Serializable - - // Corresponds to Frontend::KeyboardData - @Keep - data class KeyboardData(var result: Int, var text: String) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt deleted file mode 100644 index 607a3d506..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.applets.keyboard.ui - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.text.InputFilter -import android.text.InputType -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard -import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig -import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding -import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable - -class KeyboardDialogFragment : DialogFragment() { - private lateinit var binding: DialogEditTextBinding - private lateinit var config: KeyboardConfig - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogEditTextBinding.inflate(layoutInflater) - config = requireArguments().serializable(CONFIG)!! - - // Set up the input - binding.editText.hint = config.initial_text - binding.editText.isSingleLine = !config.enable_return_button - binding.editText.filters = - arrayOf(InputFilter.LengthFilter(config.max_text_length)) - - // Handle input type - var inputType: Int - when (config.type) { - SoftwareKeyboard.SwkbdType.Normal.ordinal, - SoftwareKeyboard.SwkbdType.Qwerty.ordinal, - SoftwareKeyboard.SwkbdType.Unknown3.ordinal, - SoftwareKeyboard.SwkbdType.Latin.ordinal, - SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, - SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, - SoftwareKeyboard.SwkbdType.Korean.ordinal -> { - inputType = InputType.TYPE_CLASS_TEXT - if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { - inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { - inputType = InputType.TYPE_CLASS_NUMBER - if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { - inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD - } - } - else -> { - inputType = InputType.TYPE_CLASS_TEXT - if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { - inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - } - binding.editText.inputType = inputType - - val headerText = - config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } - val okText = - config.ok_text!!.ifEmpty { resources.getString(R.string.submit) } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(headerText) - .setView(binding.root) - .setPositiveButton(okText) { _, _ -> - SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal - SoftwareKeyboard.data.text = binding.editText.text.toString() - } - .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> - SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal - } - .create() - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - synchronized(SoftwareKeyboard.dataLock) { - SoftwareKeyboard.dataLock.notifyAll() - } - } - - companion object { - const val TAG = "KeyboardDialogFragment" - const val CONFIG = "keyboard_config" - - fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { - val frag = KeyboardDialogFragment() - val args = Bundle() - args.putSerializable(CONFIG, config) - frag.arguments = args - return frag - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt deleted file mode 100644 index 6f4b5b13f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.disk_shader_cache - -import androidx.annotation.Keep -import androidx.lifecycle.ViewModelProvider -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.activities.EmulationActivity -import org.yuzu.yuzu_emu.model.EmulationViewModel -import org.yuzu.yuzu_emu.utils.Log - -@Keep -object DiskShaderCacheProgress { - private lateinit var emulationViewModel: EmulationViewModel - - private fun prepareViewModel() { - emulationViewModel = - ViewModelProvider( - NativeLibrary.sEmulationActivity.get() as EmulationActivity - )[EmulationViewModel::class.java] - } - - @JvmStatic - fun loadProgress(stage: Int, progress: Int, max: Int) { - val emulationActivity = NativeLibrary.sEmulationActivity.get() - if (emulationActivity == null) { - Log.error("[DiskShaderCacheProgress] EmulationActivity not present") - return - } - - emulationActivity.runOnUiThread { - when (LoadCallbackStage.values()[stage]) { - LoadCallbackStage.Prepare -> prepareViewModel() - LoadCallbackStage.Build -> emulationViewModel.updateProgress( - emulationActivity.getString(R.string.building_shaders), - progress, - max - ) - - LoadCallbackStage.Complete -> {} - } - } - } - - // Equivalent to VideoCore::LoadCallbackStage - enum class LoadCallbackStage { - Prepare, Build, Complete - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt deleted file mode 100644 index f3be156b5..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt +++ /dev/null @@ -1,341 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -// SPDX-License-Identifier: MPL-2.0 -// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) - -package org.yuzu.yuzu_emu.features - -import android.database.Cursor -import android.database.MatrixCursor -import android.os.CancellationSignal -import android.os.ParcelFileDescriptor -import android.provider.DocumentsContract -import android.provider.DocumentsProvider -import android.webkit.MimeTypeMap -import java.io.* -import org.yuzu.yuzu_emu.BuildConfig -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.getPublicFilesDir - -class DocumentProvider : DocumentsProvider() { - private val baseDirectory: File - get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) - - companion object { - private val DEFAULT_ROOT_PROJECTION: Array = arrayOf( - DocumentsContract.Root.COLUMN_ROOT_ID, - DocumentsContract.Root.COLUMN_MIME_TYPES, - DocumentsContract.Root.COLUMN_FLAGS, - DocumentsContract.Root.COLUMN_ICON, - DocumentsContract.Root.COLUMN_TITLE, - DocumentsContract.Root.COLUMN_SUMMARY, - DocumentsContract.Root.COLUMN_DOCUMENT_ID, - DocumentsContract.Root.COLUMN_AVAILABLE_BYTES - ) - - private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_MIME_TYPE, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_LAST_MODIFIED, - DocumentsContract.Document.COLUMN_FLAGS, - DocumentsContract.Document.COLUMN_SIZE - ) - - const val AUTHORITY: String = BuildConfig.APPLICATION_ID + ".user" - const val ROOT_ID: String = "root" - } - - override fun onCreate(): Boolean { - return true - } - - /** - * @return The [File] that corresponds to the document ID supplied by [getDocumentId] - */ - private fun getFile(documentId: String): File { - if (documentId.startsWith(ROOT_ID)) { - val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) - if (!file.exists()) { - throw FileNotFoundException( - "${file.absolutePath} ($documentId) not found" - ) - } - return file - } else { - throw FileNotFoundException("'$documentId' is not in any known root") - } - } - - /** - * @return A unique ID for the provided [File] - */ - private fun getDocumentId(file: File): String { - return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" - } - - override fun queryRoots(projection: Array?): Cursor { - val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) - - cursor.newRow().apply { - add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) - add(DocumentsContract.Root.COLUMN_SUMMARY, null) - add( - DocumentsContract.Root.COLUMN_FLAGS, - DocumentsContract.Root.FLAG_SUPPORTS_CREATE or - DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD - ) - add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) - add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) - add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") - add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) - add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) - } - - return cursor - } - - override fun queryDocument(documentId: String?, projection: Array?): Cursor { - val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) - return includeFile(cursor, documentId, null) - } - - override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { - return documentId?.startsWith(parentDocumentId!!) ?: false - } - - /** - * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file - */ - private fun File.resolveWithoutConflict(name: String): File { - var file = resolve(name) - if (file.exists()) { - var noConflictId = - 1 // Makes sure two files don't have the same name by adding a number to the end - val extension = name.substringAfterLast('.') - val baseName = name.substringBeforeLast('.') - while (file.exists()) - file = resolve("$baseName (${noConflictId++}).$extension") - } - return file - } - - override fun createDocument( - parentDocumentId: String?, - mimeType: String?, - displayName: String - ): String { - val parentFile = getFile(parentDocumentId!!) - val newFile = parentFile.resolveWithoutConflict(displayName) - - try { - if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { - if (!newFile.mkdir()) { - throw IOException("Failed to create directory") - } - } else { - if (!newFile.createNewFile()) { - throw IOException("Failed to create file") - } - } - } catch (e: IOException) { - throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") - } - - return getDocumentId(newFile) - } - - override fun deleteDocument(documentId: String?) { - val file = getFile(documentId!!) - if (!file.delete()) { - throw FileNotFoundException("Couldn't delete document with ID '$documentId'") - } - } - - override fun removeDocument(documentId: String, parentDocumentId: String?) { - val parent = getFile(parentDocumentId!!) - val file = getFile(documentId) - - if (parent == file || file.parentFile == null || file.parentFile!! == parent) { - if (!file.delete()) { - throw FileNotFoundException("Couldn't delete document with ID '$documentId'") - } - } else { - throw FileNotFoundException("Couldn't delete document with ID '$documentId'") - } - } - - override fun renameDocument(documentId: String?, displayName: String?): String { - if (displayName == null) { - throw FileNotFoundException( - "Couldn't rename document '$documentId' as the new name is null" - ) - } - - val sourceFile = getFile(documentId!!) - val sourceParentFile = sourceFile.parentFile - ?: throw FileNotFoundException( - "Couldn't rename document '$documentId' as it has no parent" - ) - val destFile = sourceParentFile.resolve(displayName) - - try { - if (!sourceFile.renameTo(destFile)) { - throw FileNotFoundException( - "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'" - ) - } - } catch (e: Exception) { - throw FileNotFoundException( - "Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': " + - "${e.message}" - ) - } - - return getDocumentId(destFile) - } - - private fun copyDocument( - sourceDocumentId: String, - sourceParentDocumentId: String, - targetParentDocumentId: String? - ): String { - if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) { - throw FileNotFoundException( - "Couldn't copy document '$sourceDocumentId' as its parent is not " + - "'$sourceParentDocumentId'" - ) - } - - return copyDocument(sourceDocumentId, targetParentDocumentId) - } - - override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { - val parent = getFile(targetParentDocumentId!!) - val oldFile = getFile(sourceDocumentId) - val newFile = parent.resolveWithoutConflict(oldFile.name) - - try { - if (!( - newFile.createNewFile() && newFile.setWritable(true) && - newFile.setReadable(true) - ) - ) { - throw IOException("Couldn't create new file") - } - - FileInputStream(oldFile).use { inStream -> - FileOutputStream(newFile).use { outStream -> - inStream.copyTo(outStream) - } - } - } catch (e: IOException) { - throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") - } - - return getDocumentId(newFile) - } - - override fun moveDocument( - sourceDocumentId: String, - sourceParentDocumentId: String?, - targetParentDocumentId: String? - ): String { - try { - val newDocumentId = copyDocument( - sourceDocumentId, - sourceParentDocumentId!!, - targetParentDocumentId - ) - removeDocument(sourceDocumentId, sourceParentDocumentId) - return newDocumentId - } catch (e: FileNotFoundException) { - throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") - } - } - - private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { - val localDocumentId = documentId ?: file?.let { getDocumentId(it) } - val localFile = file ?: getFile(documentId!!) - - var flags = 0 - if (localFile.isDirectory && localFile.canWrite()) { - flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE - } else if (localFile.canWrite()) { - flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE - - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME - } - - cursor.newRow().apply { - add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) - add( - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - if (localFile == baseDirectory) { - context!!.getString(R.string.app_name) - } else { - localFile.name - } - ) - add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) - add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) - add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) - add(DocumentsContract.Document.COLUMN_FLAGS, flags) - if (localFile == baseDirectory) { - add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) - } - } - - return cursor - } - - private fun getTypeForFile(file: File): Any { - return if (file.isDirectory) { - DocumentsContract.Document.MIME_TYPE_DIR - } else { - getTypeForName(file.name) - } - } - - private fun getTypeForName(name: String): Any { - val lastDot = name.lastIndexOf('.') - if (lastDot >= 0) { - val extension = name.substring(lastDot + 1) - val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) - if (mime != null) { - return mime - } - } - return "application/octect-stream" - } - - override fun queryChildDocuments( - parentDocumentId: String?, - projection: Array?, - sortOrder: String? - ): Cursor { - var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) - - val parent = getFile(parentDocumentId!!) - for (file in parent.listFiles()!!) - cursor = includeFile(cursor, null, file) - - return cursor - } - - override fun openDocument( - documentId: String?, - mode: String?, - signal: CancellationSignal? - ): ParcelFileDescriptor { - val file = documentId?.let { getFile(it) } - val accessMode = ParcelFileDescriptor.parseMode(mode) - return ParcelFileDescriptor.open(file, accessMode) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt deleted file mode 100644 index 15d776311..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/NativeInput.kt +++ /dev/null @@ -1,416 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input - -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.features.input.model.InputType -import org.yuzu.yuzu_emu.features.input.model.ButtonName -import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.ParamPackage -import android.view.InputDevice - -object NativeInput { - /** - * Default controller id for each device - */ - const val Player1Device = 0 - const val Player2Device = 1 - const val Player3Device = 2 - const val Player4Device = 3 - const val Player5Device = 4 - const val Player6Device = 5 - const val Player7Device = 6 - const val Player8Device = 7 - const val ConsoleDevice = 8 - - /** - * Button states - */ - object ButtonState { - const val RELEASED = 0 - const val PRESSED = 1 - } - - /** - * Returns true if pro controller isn't available and handheld is. - * Intended to check where the input overlay should direct its inputs. - */ - external fun isHandheldOnly(): Boolean - - /** - * Handles button press events for a gamepad. - * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. - * @param port Port determined by controller connection order. - * @param buttonId The Android Keycode corresponding to this event. - * @param action Mask identifying which action is happening (button pressed down, or button released). - */ - external fun onGamePadButtonEvent( - guid: String, - port: Int, - buttonId: Int, - action: Int - ) - - /** - * Handles axis movement events. - * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. - * @param port Port determined by controller connection order. - * @param axis The axis ID. - * @param value Value along the given axis. - */ - external fun onGamePadAxisEvent(guid: String, port: Int, axis: Int, value: Float) - - /** - * Handles motion events. - * @param guid 32 character hexadecimal string consisting of the controller's PID+VID. - * @param port Port determined by controller connection order. - * @param deltaTimestamp The finger id corresponding to this event. - * @param xGyro The value of the x-axis for the gyroscope. - * @param yGyro The value of the y-axis for the gyroscope. - * @param zGyro The value of the z-axis for the gyroscope. - * @param xAccel The value of the x-axis for the accelerometer. - * @param yAccel The value of the y-axis for the accelerometer. - * @param zAccel The value of the z-axis for the accelerometer. - */ - external fun onGamePadMotionEvent( - guid: String, - port: Int, - deltaTimestamp: Long, - xGyro: Float, - yGyro: Float, - zGyro: Float, - xAccel: Float, - yAccel: Float, - zAccel: Float - ) - - /** - * Signals and load a nfc tag - * @param data Byte array containing all the data from a nfc tag. - */ - external fun onReadNfcTag(data: ByteArray?) - - /** - * Removes current loaded nfc tag. - */ - external fun onRemoveNfcTag() - - /** - * Handles touch press events. - * @param fingerId The finger id corresponding to this event. - * @param xAxis The value of the x-axis on the touchscreen. - * @param yAxis The value of the y-axis on the touchscreen. - */ - external fun onTouchPressed(fingerId: Int, xAxis: Float, yAxis: Float) - - /** - * Handles touch movement. - * @param fingerId The finger id corresponding to this event. - * @param xAxis The value of the x-axis on the touchscreen. - * @param yAxis The value of the y-axis on the touchscreen. - */ - external fun onTouchMoved(fingerId: Int, xAxis: Float, yAxis: Float) - - /** - * Handles touch release events. - * @param fingerId The finger id corresponding to this event - */ - external fun onTouchReleased(fingerId: Int) - - /** - * Sends a button input to the global virtual controllers. - * @param port Port determined by controller connection order. - * @param button The [NativeButton] corresponding to this event. - * @param action Mask identifying which action is happening (button pressed down, or button released). - */ - fun onOverlayButtonEvent(port: Int, button: NativeButton, action: Int) = - onOverlayButtonEventImpl(port, button.int, action) - - private external fun onOverlayButtonEventImpl(port: Int, buttonId: Int, action: Int) - - /** - * Sends a joystick input to the global virtual controllers. - * @param port Port determined by controller connection order. - * @param stick The [NativeAnalog] corresponding to this event. - * @param xAxis Value along the X axis. - * @param yAxis Value along the Y axis. - */ - fun onOverlayJoystickEvent(port: Int, stick: NativeAnalog, xAxis: Float, yAxis: Float) = - onOverlayJoystickEventImpl(port, stick.int, xAxis, yAxis) - - private external fun onOverlayJoystickEventImpl( - port: Int, - stickId: Int, - xAxis: Float, - yAxis: Float - ) - - /** - * Handles motion events for the global virtual controllers. - * @param port Port determined by controller connection order - * @param deltaTimestamp The finger id corresponding to this event. - * @param xGyro The value of the x-axis for the gyroscope. - * @param yGyro The value of the y-axis for the gyroscope. - * @param zGyro The value of the z-axis for the gyroscope. - * @param xAccel The value of the x-axis for the accelerometer. - * @param yAccel The value of the y-axis for the accelerometer. - * @param zAccel The value of the z-axis for the accelerometer. - */ - external fun onDeviceMotionEvent( - port: Int, - deltaTimestamp: Long, - xGyro: Float, - yGyro: Float, - zGyro: Float, - xAccel: Float, - yAccel: Float, - zAccel: Float - ) - - /** - * Reloads all input devices from the currently loaded Settings::values.players into HID Core - */ - external fun reloadInputDevices() - - /** - * Registers a controller to be used with mapping - * @param device An [InputDevice] or the input overlay wrapped with [YuzuInputDevice] - */ - external fun registerController(device: YuzuInputDevice) - - /** - * Gets the names of input devices that have been registered with the input subsystem via [registerController] - */ - external fun getInputDevices(): Array - - /** - * Reads all input profiles from disk. Must be called before creating a profile picker. - */ - external fun loadInputProfiles() - - /** - * Gets the names of each available input profile. - */ - external fun getInputProfileNames(): Array - - /** - * Checks if the user-provided name for an input profile is valid. - * @param name User-provided name for an input profile. - * @return Whether [name] is valid or not. - */ - external fun isProfileNameValid(name: String): Boolean - - /** - * Creates a new input profile. - * @param name The new profile's name. - * @param playerIndex Index of the player that's currently being edited. Used to write the profile - * name to this player's config. - * @return Whether creating the profile was successful or not. - */ - external fun createProfile(name: String, playerIndex: Int): Boolean - - /** - * Deletes an input profile. - * @param name Name of the profile to delete. - * @param playerIndex Index of the player that's currently being edited. Used to remove the profile - * name from this player's config if they have it loaded. - * @return Whether deleting this profile was successful or not. - */ - external fun deleteProfile(name: String, playerIndex: Int): Boolean - - /** - * Loads an input profile. - * @param name Name of the input profile to load. - * @param playerIndex Index of the player that will have this profile loaded. - * @return Whether loading this profile was successful or not. - */ - external fun loadProfile(name: String, playerIndex: Int): Boolean - - /** - * Saves an input profile. - * @param name Name of the profile to save. - * @param playerIndex Index of the player that's currently being edited. Used to write the profile - * name to this player's config. - * @return Whether saving the profile was successful or not. - */ - external fun saveProfile(name: String, playerIndex: Int): Boolean - - /** - * Intended to be used immediately before a call to [NativeConfig.saveControlPlayerValues] - * Must be used while per-game config is loaded. - */ - external fun loadPerGameConfiguration( - playerIndex: Int, - selectedIndex: Int, - selectedProfileName: String - ) - - /** - * Tells the input subsystem to start listening for inputs to map. - * @param type Type of input to map as shown by the int property in each [InputType]. - */ - external fun beginMapping(type: Int) - - /** - * Gets an input's [ParamPackage] as a serialized string. Used for input verification before mapping. - * Must be run after [beginMapping] and before [stopMapping]. - */ - external fun getNextInput(): String - - /** - * Tells the input subsystem to stop listening for inputs to map. - */ - external fun stopMapping() - - /** - * Updates a controller's mappings with auto-mapping params. - * @param playerIndex Index of the player to auto-map. - * @param deviceParams [ParamPackage] representing the device to auto-map as received - * from [getInputDevices]. - * @param displayName Name of the device to auto-map as received from the "display" param in [deviceParams]. - * Intended to be a way to provide a default name for a controller if the "display" param is empty. - */ - fun updateMappingsWithDefault( - playerIndex: Int, - deviceParams: ParamPackage, - displayName: String - ) = updateMappingsWithDefaultImpl(playerIndex, deviceParams.serialize(), displayName) - - private external fun updateMappingsWithDefaultImpl( - playerIndex: Int, - deviceParams: String, - displayName: String - ) - - /** - * Gets the params for a specific button. - * @param playerIndex Index of the player to get params from. - * @param button The [NativeButton] to get params for. - * @return A [ParamPackage] representing a player's specific button. - */ - fun getButtonParam(playerIndex: Int, button: NativeButton): ParamPackage = - ParamPackage(getButtonParamImpl(playerIndex, button.int)) - - private external fun getButtonParamImpl(playerIndex: Int, buttonId: Int): String - - /** - * Sets the params for a specific button. - * @param playerIndex Index of the player to set params for. - * @param button The [NativeButton] to set params for. - * @param param A [ParamPackage] to set. - */ - fun setButtonParam(playerIndex: Int, button: NativeButton, param: ParamPackage) = - setButtonParamImpl(playerIndex, button.int, param.serialize()) - - private external fun setButtonParamImpl(playerIndex: Int, buttonId: Int, param: String) - - /** - * Gets the params for a specific stick. - * @param playerIndex Index of the player to get params from. - * @param stick The [NativeAnalog] to get params for. - * @return A [ParamPackage] representing a player's specific stick. - */ - fun getStickParam(playerIndex: Int, stick: NativeAnalog): ParamPackage = - ParamPackage(getStickParamImpl(playerIndex, stick.int)) - - private external fun getStickParamImpl(playerIndex: Int, stickId: Int): String - - /** - * Sets the params for a specific stick. - * @param playerIndex Index of the player to set params for. - * @param stick The [NativeAnalog] to set params for. - * @param param A [ParamPackage] to set. - */ - fun setStickParam(playerIndex: Int, stick: NativeAnalog, param: ParamPackage) = - setStickParamImpl(playerIndex, stick.int, param.serialize()) - - private external fun setStickParamImpl(playerIndex: Int, stickId: Int, param: String) - - /** - * Gets the int representation of a [ButtonName]. Tells you what to show as the mapped input for - * a button/analog/other. - * @param param A [ParamPackage] that represents a specific button's params. - * @return The [ButtonName] for [param]. - */ - fun getButtonName(param: ParamPackage): ButtonName = - ButtonName.from(getButtonNameImpl(param.serialize())) - - private external fun getButtonNameImpl(param: String): Int - - /** - * Gets each supported [NpadStyleIndex] for a given player. - * @param playerIndex Index of the player to get supported indexes for. - * @return List of each supported [NpadStyleIndex]. - */ - fun getSupportedStyleTags(playerIndex: Int): List = - getSupportedStyleTagsImpl(playerIndex).map { NpadStyleIndex.from(it) } - - private external fun getSupportedStyleTagsImpl(playerIndex: Int): IntArray - - /** - * Gets the [NpadStyleIndex] for a given player. - * @param playerIndex Index of the player to get an [NpadStyleIndex] from. - * @return The [NpadStyleIndex] for a given player. - */ - fun getStyleIndex(playerIndex: Int): NpadStyleIndex = - NpadStyleIndex.from(getStyleIndexImpl(playerIndex)) - - private external fun getStyleIndexImpl(playerIndex: Int): Int - - /** - * Sets the [NpadStyleIndex] for a given player. - * @param playerIndex Index of the player to change. - * @param style The new style to set. - */ - fun setStyleIndex(playerIndex: Int, style: NpadStyleIndex) = - setStyleIndexImpl(playerIndex, style.int) - - private external fun setStyleIndexImpl(playerIndex: Int, styleIndex: Int) - - /** - * Checks if a device is a controller. - * @param params [ParamPackage] for an input device retrieved from [getInputDevices] - * @return Whether the device is a controller or not. - */ - fun isController(params: ParamPackage): Boolean = isControllerImpl(params.serialize()) - - private external fun isControllerImpl(params: String): Boolean - - /** - * Checks if a controller is connected - * @param playerIndex Index of the player to check. - * @return Whether the player is connected or not. - */ - external fun getIsConnected(playerIndex: Int): Boolean - - /** - * Connects/disconnects a controller and ensures that connection order stays in-tact. - * @param playerIndex Index of the player to connect/disconnect. - * @param connected Whether to connect or disconnect this controller. - */ - fun connectControllers(playerIndex: Int, connected: Boolean = true) { - val connectedControllers = mutableListOf().apply { - if (connected) { - for (i in 0 until 8) { - add(i <= playerIndex) - } - } else { - for (i in 0 until 8) { - add(i < playerIndex) - } - } - } - connectControllersImpl(connectedControllers.toBooleanArray()) - } - - private external fun connectControllersImpl(connected: BooleanArray) - - /** - * Resets all of the button and analog mappings for a player. - * @param playerIndex Index of the player that will have its mappings reset. - */ - external fun resetControllerMappings(playerIndex: Int) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt deleted file mode 100644 index 15cc38c7f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuInputDevice.kt +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input - -import android.view.InputDevice -import androidx.annotation.Keep -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.utils.InputHandler.getGUID - -@Keep -interface YuzuInputDevice { - fun getName(): String - - fun getGUID(): String - - fun getPort(): Int - - fun getSupportsVibration(): Boolean - - fun vibrate(intensity: Float) - - fun getAxes(): Array = arrayOf() - fun hasKeys(keys: IntArray): BooleanArray = BooleanArray(0) -} - -class YuzuPhysicalDevice( - private val device: InputDevice, - private val port: Int, - useSystemVibrator: Boolean -) : YuzuInputDevice { - private val vibrator = if (useSystemVibrator) { - YuzuVibrator.getSystemVibrator() - } else { - YuzuVibrator.getControllerVibrator(device) - } - - override fun getName(): String { - return device.name - } - - override fun getGUID(): String { - return device.getGUID() - } - - override fun getPort(): Int { - return port - } - - override fun getSupportsVibration(): Boolean { - return vibrator.supportsVibration() - } - - override fun vibrate(intensity: Float) { - vibrator.vibrate(intensity) - } - - override fun getAxes(): Array = device.motionRanges.map { it.axis }.toTypedArray() - override fun hasKeys(keys: IntArray): BooleanArray = device.hasKeys(*keys) -} - -class YuzuInputOverlayDevice( - private val vibration: Boolean, - private val port: Int -) : YuzuInputDevice { - private val vibrator = YuzuVibrator.getSystemVibrator() - - override fun getName(): String { - return YuzuApplication.appContext.getString(R.string.input_overlay) - } - - override fun getGUID(): String { - return "00000000000000000000000000000000" - } - - override fun getPort(): Int { - return port - } - - override fun getSupportsVibration(): Boolean { - if (vibration) { - return vibrator.supportsVibration() - } - return false - } - - override fun vibrate(intensity: Float) { - if (vibration) { - vibrator.vibrate(intensity) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt deleted file mode 100644 index aac49ecae..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/YuzuVibrator.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input - -import android.content.Context -import android.os.Build -import android.os.CombinedVibration -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager -import android.view.InputDevice -import androidx.annotation.Keep -import androidx.annotation.RequiresApi -import org.yuzu.yuzu_emu.YuzuApplication - -@Keep -@Suppress("DEPRECATION") -interface YuzuVibrator { - fun supportsVibration(): Boolean - - fun vibrate(intensity: Float) - - companion object { - fun getControllerVibrator(device: InputDevice): YuzuVibrator = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - YuzuVibratorManager(device.vibratorManager) - } else { - YuzuVibratorManagerCompat(device.vibrator) - } - - fun getSystemVibrator(): YuzuVibrator = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = YuzuApplication.appContext - .getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager - YuzuVibratorManager(vibratorManager) - } else { - val vibrator = YuzuApplication.appContext - .getSystemService(Context.VIBRATOR_SERVICE) as Vibrator - YuzuVibratorManagerCompat(vibrator) - } - - fun getVibrationEffect(intensity: Float): VibrationEffect? { - if (intensity > 0f) { - return VibrationEffect.createOneShot( - 50, - (255.0 * intensity).toInt().coerceIn(1, 255) - ) - } - return null - } - } -} - -@RequiresApi(Build.VERSION_CODES.S) -class YuzuVibratorManager(private val vibratorManager: VibratorManager) : YuzuVibrator { - override fun supportsVibration(): Boolean { - return vibratorManager.vibratorIds.isNotEmpty() - } - - override fun vibrate(intensity: Float) { - val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return - vibratorManager.vibrate(CombinedVibration.createParallel(vibration)) - } -} - -class YuzuVibratorManagerCompat(private val vibrator: Vibrator) : YuzuVibrator { - override fun supportsVibration(): Boolean { - return vibrator.hasVibrator() - } - - override fun vibrate(intensity: Float) { - val vibration = YuzuVibrator.getVibrationEffect(intensity) ?: return - vibrator.vibrate(vibration) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt deleted file mode 100644 index 0a5fab2ae..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/AnalogDirection.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -enum class AnalogDirection(val int: Int, val param: String) { - Up(0, "up"), - Down(1, "down"), - Left(2, "left"), - Right(3, "right") -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt deleted file mode 100644 index b8846ecad..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/ButtonName.kt +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -// Loosely matches the enum in common/input.h -enum class ButtonName(val int: Int) { - Invalid(1), - - // This will display the engine name instead of the button name - Engine(2), - - // This will display the button by value instead of the button name - Value(3); - - companion object { - fun from(int: Int): ButtonName = entries.firstOrNull { it.int == int } ?: Invalid - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt deleted file mode 100644 index f725231cb..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/InputType.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -// Must match the corresponding enum in input_common/main.h -enum class InputType(val int: Int) { - None(0), - Button(1), - Stick(2), - Motion(3), - Touch(4) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt deleted file mode 100644 index c3b7a785d..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeAnalog.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -// Must match enum in src/common/settings_input.h -enum class NativeAnalog(val int: Int) { - LStick(0), - RStick(1); - - companion object { - fun from(int: Int): NativeAnalog = entries.firstOrNull { it.int == int } ?: LStick - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt deleted file mode 100644 index c5ccd7115..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeButton.kt +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -// Must match enum in src/common/settings_input.h -enum class NativeButton(val int: Int) { - A(0), - B(1), - X(2), - Y(3), - LStick(4), - RStick(5), - L(6), - R(7), - ZL(8), - ZR(9), - Plus(10), - Minus(11), - - DLeft(12), - DUp(13), - DRight(14), - DDown(15), - - SLLeft(16), - SRLeft(17), - - Home(18), - Capture(19), - - SLRight(20), - SRRight(21); - - companion object { - fun from(int: Int): NativeButton = entries.firstOrNull { it.int == int } ?: A - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt deleted file mode 100644 index 625f352b4..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NativeTrigger.kt +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -// Must match enum in src/common/settings_input.h -enum class NativeTrigger(val int: Int) { - LTrigger(0), - RTrigger(1) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt deleted file mode 100644 index e2a3d7aff..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/NpadStyleIndex.kt +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.R - -// Must match enum in src/core/hid/hid_types.h -enum class NpadStyleIndex(val int: Int, @StringRes val nameId: Int = 0) { - None(0), - Fullkey(3, R.string.pro_controller), - Handheld(4, R.string.handheld), - HandheldNES(4), - JoyconDual(5, R.string.dual_joycons), - JoyconLeft(6, R.string.left_joycon), - JoyconRight(7, R.string.right_joycon), - GameCube(8, R.string.gamecube_controller), - Pokeball(9), - NES(10), - SNES(12), - N64(13), - SegaGenesis(14), - SystemExt(32), - System(33); - - companion object { - fun from(int: Int): NpadStyleIndex = entries.firstOrNull { it.int == int } ?: None - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt deleted file mode 100644 index a84ac77a2..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/input/model/PlayerInput.kt +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.input.model - -import androidx.annotation.Keep - -@Keep -data class PlayerInput( - var connected: Boolean, - var buttons: Array, - var analogs: Array, - var motions: Array, - - var vibrationEnabled: Boolean, - var vibrationStrength: Int, - - var bodyColorLeft: Long, - var bodyColorRight: Long, - var buttonColorLeft: Long, - var buttonColorRight: Long, - var profileName: String, - - var useSystemVibrator: Boolean -) { - // It's recommended to use the generated equals() and hashCode() methods - // when using arrays in a data class - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as PlayerInput - - if (connected != other.connected) return false - if (!buttons.contentEquals(other.buttons)) return false - if (!analogs.contentEquals(other.analogs)) return false - if (!motions.contentEquals(other.motions)) return false - if (vibrationEnabled != other.vibrationEnabled) return false - if (vibrationStrength != other.vibrationStrength) return false - if (bodyColorLeft != other.bodyColorLeft) return false - if (bodyColorRight != other.bodyColorRight) return false - if (buttonColorLeft != other.buttonColorLeft) return false - if (buttonColorRight != other.buttonColorRight) return false - if (profileName != other.profileName) return false - return useSystemVibrator == other.useSystemVibrator - } - - override fun hashCode(): Int { - var result = connected.hashCode() - result = 31 * result + buttons.contentHashCode() - result = 31 * result + analogs.contentHashCode() - result = 31 * result + motions.contentHashCode() - result = 31 * result + vibrationEnabled.hashCode() - result = 31 * result + vibrationStrength - result = 31 * result + bodyColorLeft.hashCode() - result = 31 * result + bodyColorRight.hashCode() - result = 31 * result + buttonColorLeft.hashCode() - result = 31 * result + buttonColorRight.hashCode() - result = 31 * result + profileName.hashCode() - result = 31 * result + useSystemVibrator.hashCode() - return result - } - - fun hasMapping(): Boolean { - var hasMapping = false - buttons.forEach { - if (it != "[empty]" && it.isNotEmpty()) { - hasMapping = true - } - } - analogs.forEach { - if (it != "[empty]" && it.isNotEmpty()) { - hasMapping = true - } - } - motions.forEach { - if (it != "[empty]" && it.isNotEmpty()) { - hasMapping = true - } - } - return hasMapping - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt deleted file mode 100644 index 0ba465356..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractBooleanSetting : AbstractSetting { - fun getBoolean(needsGlobal: Boolean = false): Boolean - fun setBoolean(value: Boolean) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractByteSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractByteSetting.kt deleted file mode 100644 index cf6300535..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractByteSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractByteSetting : AbstractSetting { - fun getByte(needsGlobal: Boolean = false): Byte - fun setByte(value: Byte) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt deleted file mode 100644 index c6c0bcf34..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractFloatSetting : AbstractSetting { - fun getFloat(needsGlobal: Boolean = false): Float - fun setFloat(value: Float) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt deleted file mode 100644 index 826402c34..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractIntSetting : AbstractSetting { - fun getInt(needsGlobal: Boolean = false): Int - fun setInt(value: Int) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractLongSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractLongSetting.kt deleted file mode 100644 index 2b62cc06b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractLongSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractLongSetting : AbstractSetting { - fun getLong(needsGlobal: Boolean = false): Long - fun setLong(value: Long) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt deleted file mode 100644 index 3b78c7cf0..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -interface AbstractSetting { - val key: String - val defaultValue: Any - - val isRuntimeModifiable: Boolean - get() = NativeConfig.getIsRuntimeModifiable(key) - - val pairedSettingKey: String - get() = NativeConfig.getPairedSettingKey(key) - - val isSwitchable: Boolean - get() = NativeConfig.getIsSwitchable(key) - - var global: Boolean - get() = NativeConfig.usingGlobal(key) - set(value) = NativeConfig.setGlobal(key, value) - - val isSaveable: Boolean - get() = NativeConfig.getIsSaveable(key) - - fun getValueAsString(needsGlobal: Boolean = false): String - - fun reset() -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractShortSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractShortSetting.kt deleted file mode 100644 index 8bfa81e4a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractShortSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractShortSetting : AbstractSetting { - fun getShort(needsGlobal: Boolean = false): Short - fun setShort(value: Short) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt deleted file mode 100644 index 6ff8fd3f9..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -interface AbstractStringSetting : AbstractSetting { - fun getString(needsGlobal: Boolean = false): String - fun setString(value: String) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt deleted file mode 100644 index 664478472..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class BooleanSetting(override val key: String) : AbstractBooleanSetting { - AUDIO_MUTED("audio_muted"), - CPU_DEBUG_MODE("cpu_debug_mode"), - FASTMEM("cpuopt_fastmem"), - FASTMEM_EXCLUSIVES("cpuopt_fastmem_exclusives"), - RENDERER_USE_SPEED_LIMIT("use_speed_limit"), - USE_DOCKED_MODE("use_docked_mode"), - RENDERER_USE_DISK_SHADER_CACHE("use_disk_shader_cache"), - RENDERER_FORCE_MAX_CLOCK("force_max_clock"), - RENDERER_ASYNCHRONOUS_SHADERS("use_asynchronous_shaders"), - RENDERER_REACTIVE_FLUSHING("use_reactive_flushing"), - RENDERER_DEBUG("debug"), - PICTURE_IN_PICTURE("picture_in_picture"), - USE_CUSTOM_RTC("custom_rtc_enabled"), - BLACK_BACKGROUNDS("black_backgrounds"), - JOYSTICK_REL_CENTER("joystick_rel_center"), - DPAD_SLIDE("dpad_slide"), - HAPTIC_FEEDBACK("haptic_feedback"), - SHOW_PERFORMANCE_OVERLAY("show_performance_overlay"), - SHOW_INPUT_OVERLAY("show_input_overlay"), - TOUCHSCREEN("touchscreen"), - SHOW_THERMAL_OVERLAY("show_thermal_overlay"); - - override fun getBoolean(needsGlobal: Boolean): Boolean = - NativeConfig.getBoolean(key, needsGlobal) - - override fun setBoolean(value: Boolean) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setBoolean(key, value) - } - - override val defaultValue: Boolean by lazy { NativeConfig.getDefaultToString(key).toBoolean() } - - override fun getValueAsString(needsGlobal: Boolean): String = getBoolean(needsGlobal).toString() - - override fun reset() = NativeConfig.setBoolean(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ByteSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ByteSetting.kt deleted file mode 100644 index 7b7fac211..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ByteSetting.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class ByteSetting(override val key: String) : AbstractByteSetting { - AUDIO_VOLUME("volume"); - - override fun getByte(needsGlobal: Boolean): Byte = NativeConfig.getByte(key, needsGlobal) - - override fun setByte(value: Byte) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setByte(key, value) - } - - override val defaultValue: Byte by lazy { NativeConfig.getDefaultToString(key).toByte() } - - override fun getValueAsString(needsGlobal: Boolean): String = getByte(needsGlobal).toString() - - override fun reset() = NativeConfig.setByte(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt deleted file mode 100644 index 4644824d8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class FloatSetting(override val key: String) : AbstractFloatSetting { - // No float settings currently exist - EMPTY_SETTING(""); - - override fun getFloat(needsGlobal: Boolean): Float = NativeConfig.getFloat(key, false) - - override fun setFloat(value: Float) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setFloat(key, value) - } - - override val defaultValue: Float by lazy { NativeConfig.getDefaultToString(key).toFloat() } - - override fun getValueAsString(needsGlobal: Boolean): String = getFloat(needsGlobal).toString() - - override fun reset() = NativeConfig.setFloat(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt deleted file mode 100644 index 0165cb2d1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class IntSetting(override val key: String) : AbstractIntSetting { - CPU_BACKEND("cpu_backend"), - CPU_ACCURACY("cpu_accuracy"), - REGION_INDEX("region_index"), - LANGUAGE_INDEX("language_index"), - RENDERER_BACKEND("backend"), - RENDERER_ACCURACY("gpu_accuracy"), - RENDERER_RESOLUTION("resolution_setup"), - RENDERER_VSYNC("use_vsync"), - RENDERER_SCALING_FILTER("scaling_filter"), - RENDERER_ANTI_ALIASING("anti_aliasing"), - RENDERER_SCREEN_LAYOUT("screen_layout"), - RENDERER_ASPECT_RATIO("aspect_ratio"), - AUDIO_OUTPUT_ENGINE("output_engine"), - MAX_ANISOTROPY("max_anisotropy"), - THEME("theme"), - THEME_MODE("theme_mode"), - OVERLAY_SCALE("control_scale"), - OVERLAY_OPACITY("control_opacity"), - LOCK_DRAWER("lock_drawer"), - VERTICAL_ALIGNMENT("vertical_alignment"), - FSR_SHARPENING_SLIDER("fsr_sharpening_slider"); - - override fun getInt(needsGlobal: Boolean): Int = NativeConfig.getInt(key, needsGlobal) - - override fun setInt(value: Int) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setInt(key, value) - } - - override val defaultValue: Int by lazy { NativeConfig.getDefaultToString(key).toInt() } - - override fun getValueAsString(needsGlobal: Boolean): String = getInt(needsGlobal).toString() - - override fun reset() = NativeConfig.setInt(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/LongSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/LongSetting.kt deleted file mode 100644 index e3efd516c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/LongSetting.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class LongSetting(override val key: String) : AbstractLongSetting { - CUSTOM_RTC("custom_rtc"); - - override fun getLong(needsGlobal: Boolean): Long = NativeConfig.getLong(key, needsGlobal) - - override fun setLong(value: Long) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setLong(key, value) - } - - override val defaultValue: Long by lazy { NativeConfig.getDefaultToString(key).toLong() } - - override fun getValueAsString(needsGlobal: Boolean): String = getLong(needsGlobal).toString() - - override fun reset() = NativeConfig.setLong(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt deleted file mode 100644 index 4f6b93bd2..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt +++ /dev/null @@ -1,120 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication - -object Settings { - enum class MenuTag(val titleId: Int = 0) { - SECTION_ROOT(R.string.advanced_settings), - SECTION_SYSTEM(R.string.preferences_system), - SECTION_RENDERER(R.string.preferences_graphics), - SECTION_AUDIO(R.string.preferences_audio), - SECTION_INPUT(R.string.preferences_controls), - SECTION_INPUT_PLAYER_ONE, - SECTION_INPUT_PLAYER_TWO, - SECTION_INPUT_PLAYER_THREE, - SECTION_INPUT_PLAYER_FOUR, - SECTION_INPUT_PLAYER_FIVE, - SECTION_INPUT_PLAYER_SIX, - SECTION_INPUT_PLAYER_SEVEN, - SECTION_INPUT_PLAYER_EIGHT, - SECTION_THEME(R.string.preferences_theme), - SECTION_DEBUG(R.string.preferences_debug); - } - - fun getPlayerString(player: Int): String = - YuzuApplication.appContext.getString(R.string.preferences_player, player) - - const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" - const val PREF_MEMORY_WARNING_SHOWN = "MemoryWarningShown" - - // Deprecated input overlay preference keys - const val PREF_CONTROL_SCALE = "controlScale" - const val PREF_CONTROL_OPACITY = "controlOpacity" - const val PREF_TOUCH_ENABLED = "isTouchEnabled" - const val PREF_BUTTON_A = "buttonToggle0" - const val PREF_BUTTON_B = "buttonToggle1" - const val PREF_BUTTON_X = "buttonToggle2" - const val PREF_BUTTON_Y = "buttonToggle3" - const val PREF_BUTTON_L = "buttonToggle4" - const val PREF_BUTTON_R = "buttonToggle5" - const val PREF_BUTTON_ZL = "buttonToggle6" - const val PREF_BUTTON_ZR = "buttonToggle7" - const val PREF_BUTTON_PLUS = "buttonToggle8" - const val PREF_BUTTON_MINUS = "buttonToggle9" - const val PREF_BUTTON_DPAD = "buttonToggle10" - const val PREF_STICK_L = "buttonToggle11" - const val PREF_STICK_R = "buttonToggle12" - const val PREF_BUTTON_STICK_L = "buttonToggle13" - const val PREF_BUTTON_STICK_R = "buttonToggle14" - const val PREF_BUTTON_HOME = "buttonToggle15" - const val PREF_BUTTON_SCREENSHOT = "buttonToggle16" - const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter" - const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable" - const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics" - const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" - const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" - val overlayPreferences = listOf( - PREF_BUTTON_A, - PREF_BUTTON_B, - PREF_BUTTON_X, - PREF_BUTTON_Y, - PREF_BUTTON_L, - PREF_BUTTON_R, - PREF_BUTTON_ZL, - PREF_BUTTON_ZR, - PREF_BUTTON_PLUS, - PREF_BUTTON_MINUS, - PREF_BUTTON_DPAD, - PREF_STICK_L, - PREF_STICK_R, - PREF_BUTTON_HOME, - PREF_BUTTON_SCREENSHOT, - PREF_BUTTON_STICK_L, - PREF_BUTTON_STICK_R - ) - - // Deprecated layout preference keys - const val PREF_LANDSCAPE_SUFFIX = "_Landscape" - const val PREF_PORTRAIT_SUFFIX = "_Portrait" - const val PREF_FOLDABLE_SUFFIX = "_Foldable" - val overlayLayoutSuffixes = listOf( - PREF_LANDSCAPE_SUFFIX, - PREF_PORTRAIT_SUFFIX, - PREF_FOLDABLE_SUFFIX - ) - - // Deprecated theme preference keys - const val PREF_THEME = "Theme" - const val PREF_THEME_MODE = "ThemeMode" - const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" - - enum class EmulationOrientation(val int: Int) { - Unspecified(0), - SensorLandscape(5), - Landscape(1), - ReverseLandscape(2), - SensorPortrait(6), - Portrait(4), - ReversePortrait(3); - - companion object { - fun from(int: Int): EmulationOrientation = - entries.firstOrNull { it.int == int } ?: Unspecified - } - } - - enum class EmulationVerticalAlignment(val int: Int) { - Top(1), - Center(0), - Bottom(2); - - companion object { - fun from(int: Int): EmulationVerticalAlignment = - entries.firstOrNull { it.int == int } ?: Center - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ShortSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ShortSetting.kt deleted file mode 100644 index 16eb4ffdd..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/ShortSetting.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class ShortSetting(override val key: String) : AbstractShortSetting { - RENDERER_SPEED_LIMIT("speed_limit"); - - override fun getShort(needsGlobal: Boolean): Short = NativeConfig.getShort(key, needsGlobal) - - override fun setShort(value: Short) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setShort(key, value) - } - - override val defaultValue: Short by lazy { NativeConfig.getDefaultToString(key).toShort() } - - override fun getValueAsString(needsGlobal: Boolean): String = getShort(needsGlobal).toString() - - override fun reset() = NativeConfig.setShort(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt deleted file mode 100644 index 6f16cf5b1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt +++ /dev/null @@ -1,26 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model - -import org.yuzu.yuzu_emu.utils.NativeConfig - -enum class StringSetting(override val key: String) : AbstractStringSetting { - DRIVER_PATH("driver_path"), - DEVICE_NAME("device_name"); - - override fun getString(needsGlobal: Boolean): String = NativeConfig.getString(key, needsGlobal) - - override fun setString(value: String) { - if (NativeConfig.isPerGameConfigLoaded()) { - global = false - } - NativeConfig.setString(key, value) - } - - override val defaultValue: String by lazy { NativeConfig.getDefaultToString(key) } - - override fun getValueAsString(needsGlobal: Boolean): String = getString(needsGlobal) - - override fun reset() = NativeConfig.setString(key, defaultValue) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt deleted file mode 100644 index a2996725e..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/AnalogInputSetting.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.AnalogDirection -import org.yuzu.yuzu_emu.features.input.model.InputType -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.utils.ParamPackage - -class AnalogInputSetting( - override val playerIndex: Int, - val nativeAnalog: NativeAnalog, - val analogDirection: AnalogDirection, - @StringRes titleId: Int = 0, - titleString: String = "" -) : InputSetting(titleId, titleString) { - override val type = TYPE_INPUT - override val inputType = InputType.Stick - - override fun getSelectedValue(): String { - val params = NativeInput.getStickParam(playerIndex, nativeAnalog) - val analog = analogToText(params, analogDirection.param) - return getDisplayString(params, analog) - } - - override fun setSelectedValue(param: ParamPackage) = - NativeInput.setStickParam(playerIndex, nativeAnalog, param) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt deleted file mode 100644 index 786d09a7a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ButtonInputSetting.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.utils.ParamPackage -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.InputType -import org.yuzu.yuzu_emu.features.input.model.NativeButton - -class ButtonInputSetting( - override val playerIndex: Int, - val nativeButton: NativeButton, - @StringRes titleId: Int = 0, - titleString: String = "" -) : InputSetting(titleId, titleString) { - override val type = TYPE_INPUT - override val inputType = InputType.Button - - override fun getSelectedValue(): String { - val params = NativeInput.getButtonParam(playerIndex, nativeButton) - val button = buttonToText(params) - return getDisplayString(params, button) - } - - override fun setSelectedValue(param: ParamPackage) = - NativeInput.setButtonParam(playerIndex, nativeButton, param) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt deleted file mode 100644 index 58febff1d..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractLongSetting - -class DateTimeSetting( - private val longSetting: AbstractLongSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "" -) : SettingsItem(longSetting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_DATETIME_SETTING - - fun getValue(needsGlobal: Boolean = false): Long = longSetting.getLong(needsGlobal) - fun setValue(value: Long) = (setting as AbstractLongSetting).setLong(value) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt deleted file mode 100644 index 8a6a51d5c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes - -class HeaderSetting( - @StringRes titleId: Int = 0, - titleString: String = "" -) : SettingsItem(emptySetting, titleId, titleString, 0, "") { - override val type = TYPE_HEADER -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt deleted file mode 100644 index c46de08c5..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputProfileSetting.kt +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.utils.NativeConfig - -class InputProfileSetting(private val playerIndex: Int) : - SettingsItem(emptySetting, R.string.profile, "", 0, "") { - override val type = TYPE_INPUT_PROFILE - - fun getCurrentProfile(): String = - NativeConfig.getInputSettings(true)[playerIndex].profileName - - fun getProfileNames(): Array = NativeInput.getInputProfileNames() - - fun isProfileNameValid(name: String): Boolean = NativeInput.isProfileNameValid(name) - - fun createProfile(name: String): Boolean = NativeInput.createProfile(name, playerIndex) - - fun deleteProfile(name: String): Boolean = NativeInput.deleteProfile(name, playerIndex) - - fun loadProfile(name: String): Boolean { - val result = NativeInput.loadProfile(name, playerIndex) - NativeInput.reloadInputDevices() - return result - } - - fun saveProfile(name: String): Boolean = NativeInput.saveProfile(name, playerIndex) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt deleted file mode 100644 index 2d118bff3..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/InputSetting.kt +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.ButtonName -import org.yuzu.yuzu_emu.features.input.model.InputType -import org.yuzu.yuzu_emu.utils.ParamPackage - -sealed class InputSetting( - @StringRes titleId: Int, - titleString: String -) : SettingsItem(emptySetting, titleId, titleString, 0, "") { - override val type = TYPE_INPUT - abstract val inputType: InputType - abstract val playerIndex: Int - - protected val context get() = YuzuApplication.appContext - - abstract fun getSelectedValue(): String - - abstract fun setSelectedValue(param: ParamPackage) - - protected fun getDisplayString(params: ParamPackage, control: String): String { - val deviceName = params.get("display", "") - deviceName.ifEmpty { - return context.getString(R.string.not_set) - } - return "$deviceName: $control" - } - - private fun getDirectionName(direction: String): String = - when (direction) { - "up" -> context.getString(R.string.up) - "down" -> context.getString(R.string.down) - "left" -> context.getString(R.string.left) - "right" -> context.getString(R.string.right) - else -> direction - } - - protected fun buttonToText(param: ParamPackage): String { - if (!param.has("engine")) { - return context.getString(R.string.not_set) - } - - val toggle = if (param.get("toggle", false)) "~" else "" - val inverted = if (param.get("inverted", false)) "!" else "" - val invert = if (param.get("invert", "+") == "-") "-" else "" - val turbo = if (param.get("turbo", false)) "$" else "" - val commonButtonName = NativeInput.getButtonName(param) - - if (commonButtonName == ButtonName.Invalid) { - return context.getString(R.string.invalid) - } - - if (commonButtonName == ButtonName.Engine) { - return param.get("engine", "") - } - - if (commonButtonName == ButtonName.Value) { - if (param.has("hat")) { - val hat = getDirectionName(param.get("direction", "")) - return context.getString(R.string.qualified_hat, turbo, toggle, inverted, hat) - } - if (param.has("axis")) { - val axis = param.get("axis", "") - return context.getString( - R.string.qualified_button_stick_axis, - toggle, - inverted, - invert, - axis - ) - } - if (param.has("button")) { - val button = param.get("button", "") - return context.getString(R.string.qualified_button, turbo, toggle, inverted, button) - } - } - - return context.getString(R.string.unknown) - } - - protected fun analogToText(param: ParamPackage, direction: String): String { - if (!param.has("engine")) { - return context.getString(R.string.not_set) - } - - if (param.get("engine", "") == "analog_from_button") { - return buttonToText(ParamPackage(param.get(direction, ""))) - } - - if (!param.has("axis_x") || !param.has("axis_y")) { - return context.getString(R.string.unknown) - } - - val xAxis = param.get("axis_x", "") - val yAxis = param.get("axis_y", "") - val xInvert = param.get("invert_x", "+") == "-" - val yInvert = param.get("invert_y", "+") == "-" - - if (direction == "modifier") { - return context.getString(R.string.unused) - } - - when (direction) { - "up" -> { - val yInvertString = if (yInvert) "+" else "-" - return context.getString(R.string.qualified_axis, yAxis, yInvertString) - } - - "down" -> { - val yInvertString = if (yInvert) "-" else "+" - return context.getString(R.string.qualified_axis, yAxis, yInvertString) - } - - "left" -> { - val xInvertString = if (xInvert) "+" else "-" - return context.getString(R.string.qualified_axis, xAxis, xInvertString) - } - - "right" -> { - val xInvertString = if (xInvert) "-" else "+" - return context.getString(R.string.qualified_axis, xAxis, xInvertString) - } - } - - return context.getString(R.string.unknown) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt deleted file mode 100644 index e024c793a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/IntSingleChoiceSetting.kt +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting - -class IntSingleChoiceSetting( - private val intSetting: AbstractIntSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - val choices: Array, - val values: Array -) : SettingsItem(intSetting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_INT_SINGLE_CHOICE - - fun getValueAt(index: Int): Int = - if (values.indices.contains(index)) values[index] else -1 - - fun getChoiceAt(index: Int): String = - if (choices.indices.contains(index)) choices[index] else "" - - fun getSelectedValue(needsGlobal: Boolean = false) = intSetting.getInt(needsGlobal) - fun setSelectedValue(value: Int) = intSetting.setInt(value) - - val selectedValueIndex: Int - get() { - for (i in values.indices) { - if (values[i] == getSelectedValue()) { - return i - } - } - return -1 - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt deleted file mode 100644 index a1db3cc87..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/ModifierInputSetting.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.InputType -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.utils.ParamPackage - -class ModifierInputSetting( - override val playerIndex: Int, - val nativeAnalog: NativeAnalog, - @StringRes titleId: Int = 0, - titleString: String = "" -) : InputSetting(titleId, titleString) { - override val inputType = InputType.Button - - override fun getSelectedValue(): String { - val analogParam = NativeInput.getStickParam(playerIndex, nativeAnalog) - val modifierParam = ParamPackage(analogParam.get("modifier", "")) - return buttonToText(modifierParam) - } - - override fun setSelectedValue(param: ParamPackage) { - val newParam = NativeInput.getStickParam(playerIndex, nativeAnalog) - newParam.set("modifier", param.serialize()) - NativeInput.setStickParam(playerIndex, nativeAnalog, newParam) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt deleted file mode 100644 index 06f607424..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes - -class RunnableSetting( - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - val isRunnable: Boolean, - @DrawableRes val iconId: Int = 0, - val runnable: () -> Unit -) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_RUNNABLE -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt deleted file mode 100644 index 5fdf98318..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt +++ /dev/null @@ -1,391 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex -import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.ByteSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.features.settings.model.LongSetting -import org.yuzu.yuzu_emu.features.settings.model.ShortSetting -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import org.yuzu.yuzu_emu.utils.NativeConfig - -/** - * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. - * Each one corresponds to a [AbstractSetting] object, so this class's subclasses - * should vaguely correspond to those subclasses. There are a few with multiple analogues - * and a few with none (Headers, for example, do not correspond to anything in the ini - * file.) - */ -abstract class SettingsItem( - val setting: AbstractSetting, - @StringRes val titleId: Int, - val titleString: String, - @StringRes val descriptionId: Int, - val descriptionString: String -) { - abstract val type: Int - - val title: String by lazy { - if (titleId != 0) { - return@lazy YuzuApplication.appContext.getString(titleId) - } - return@lazy titleString - } - - val description: String by lazy { - if (descriptionId != 0) { - return@lazy YuzuApplication.appContext.getString(descriptionId) - } - return@lazy descriptionString - } - - val isEditable: Boolean - get() { - // Can't change docked mode toggle when using handheld mode - if (setting.key == BooleanSetting.USE_DOCKED_MODE.key) { - return NativeInput.getStyleIndex(0) != NpadStyleIndex.Handheld - } - - // Can't edit settings that aren't saveable in per-game config even if they are switchable - if (NativeConfig.isPerGameConfigLoaded() && !setting.isSaveable) { - return false - } - - if (!NativeLibrary.isRunning()) return true - - // Prevent editing settings that were modified in per-game config while editing global - // config - if (!NativeConfig.isPerGameConfigLoaded() && !setting.global) { - return false - } - - return setting.isRuntimeModifiable - } - - val needsRuntimeGlobal: Boolean - get() = NativeLibrary.isRunning() && !setting.global && - !NativeConfig.isPerGameConfigLoaded() - - val clearable: Boolean - get() = !setting.global && NativeConfig.isPerGameConfigLoaded() - - companion object { - const val TYPE_HEADER = 0 - const val TYPE_SWITCH = 1 - const val TYPE_SINGLE_CHOICE = 2 - const val TYPE_SLIDER = 3 - const val TYPE_SUBMENU = 4 - const val TYPE_STRING_SINGLE_CHOICE = 5 - const val TYPE_DATETIME_SETTING = 6 - const val TYPE_RUNNABLE = 7 - const val TYPE_INPUT = 8 - const val TYPE_INT_SINGLE_CHOICE = 9 - const val TYPE_INPUT_PROFILE = 10 - const val TYPE_STRING_INPUT = 11 - - const val FASTMEM_COMBINED = "fastmem_combined" - - val emptySetting = object : AbstractSetting { - override val key: String = "" - override val defaultValue: Any = false - override val isSaveable = true - override fun getValueAsString(needsGlobal: Boolean): String = "" - override fun reset() {} - } - - // Extension for putting SettingsItems into a hashmap without repeating yourself - fun HashMap.put(item: SettingsItem) { - put(item.setting.key, item) - } - - // List of all general - val settingsItems = HashMap().apply { - put(StringInputSetting(StringSetting.DEVICE_NAME, titleId = R.string.device_name)) - put( - SwitchSetting( - BooleanSetting.RENDERER_USE_SPEED_LIMIT, - titleId = R.string.frame_limit_enable, - descriptionId = R.string.frame_limit_enable_description - ) - ) - put( - SliderSetting( - ShortSetting.RENDERER_SPEED_LIMIT, - titleId = R.string.frame_limit_slider, - descriptionId = R.string.frame_limit_slider_description, - min = 1, - max = 400, - units = "%" - ) - ) - put( - SingleChoiceSetting( - IntSetting.CPU_BACKEND, - titleId = R.string.cpu_backend, - choicesId = R.array.cpuBackendArm64Names, - valuesId = R.array.cpuBackendArm64Values - ) - ) - put( - SingleChoiceSetting( - IntSetting.CPU_ACCURACY, - titleId = R.string.cpu_accuracy, - choicesId = R.array.cpuAccuracyNames, - valuesId = R.array.cpuAccuracyValues - ) - ) - put( - SwitchSetting( - BooleanSetting.PICTURE_IN_PICTURE, - titleId = R.string.picture_in_picture, - descriptionId = R.string.picture_in_picture_description - ) - ) - - val dockedModeSetting = object : AbstractBooleanSetting { - override val key = BooleanSetting.USE_DOCKED_MODE.key - - override fun getBoolean(needsGlobal: Boolean): Boolean { - if (NativeInput.getStyleIndex(0) == NpadStyleIndex.Handheld) { - return false - } - return BooleanSetting.USE_DOCKED_MODE.getBoolean(needsGlobal) - } - - override fun setBoolean(value: Boolean) = - BooleanSetting.USE_DOCKED_MODE.setBoolean(value) - - override val defaultValue = BooleanSetting.USE_DOCKED_MODE.defaultValue - - override fun getValueAsString(needsGlobal: Boolean): String = - BooleanSetting.USE_DOCKED_MODE.getValueAsString(needsGlobal) - - override fun reset() = BooleanSetting.USE_DOCKED_MODE.reset() - } - put( - SwitchSetting( - dockedModeSetting, - titleId = R.string.use_docked_mode, - descriptionId = R.string.use_docked_mode_description - ) - ) - - put( - SingleChoiceSetting( - IntSetting.REGION_INDEX, - titleId = R.string.emulated_region, - choicesId = R.array.regionNames, - valuesId = R.array.regionValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.LANGUAGE_INDEX, - titleId = R.string.emulated_language, - choicesId = R.array.languageNames, - valuesId = R.array.languageValues - ) - ) - put( - SwitchSetting( - BooleanSetting.USE_CUSTOM_RTC, - titleId = R.string.use_custom_rtc, - descriptionId = R.string.use_custom_rtc_description - ) - ) - put(DateTimeSetting(LongSetting.CUSTOM_RTC, titleId = R.string.set_custom_rtc)) - put( - SingleChoiceSetting( - IntSetting.RENDERER_ACCURACY, - titleId = R.string.renderer_accuracy, - choicesId = R.array.rendererAccuracyNames, - valuesId = R.array.rendererAccuracyValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_RESOLUTION, - titleId = R.string.renderer_resolution, - choicesId = R.array.rendererResolutionNames, - valuesId = R.array.rendererResolutionValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_VSYNC, - titleId = R.string.renderer_vsync, - choicesId = R.array.rendererVSyncNames, - valuesId = R.array.rendererVSyncValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_SCALING_FILTER, - titleId = R.string.renderer_scaling_filter, - choicesId = R.array.rendererScalingFilterNames, - valuesId = R.array.rendererScalingFilterValues - ) - ) - put( - SliderSetting( - IntSetting.FSR_SHARPENING_SLIDER, - titleId = R.string.fsr_sharpness, - descriptionId = R.string.fsr_sharpness_description, - units = "%" - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_ANTI_ALIASING, - titleId = R.string.renderer_anti_aliasing, - choicesId = R.array.rendererAntiAliasingNames, - valuesId = R.array.rendererAntiAliasingValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_SCREEN_LAYOUT, - titleId = R.string.renderer_screen_layout, - choicesId = R.array.rendererScreenLayoutNames, - valuesId = R.array.rendererScreenLayoutValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_ASPECT_RATIO, - titleId = R.string.renderer_aspect_ratio, - choicesId = R.array.rendererAspectRatioNames, - valuesId = R.array.rendererAspectRatioValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.VERTICAL_ALIGNMENT, - titleId = R.string.vertical_alignment, - descriptionId = 0, - choicesId = R.array.verticalAlignmentEntries, - valuesId = R.array.verticalAlignmentValues - ) - ) - put( - SwitchSetting( - BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE, - titleId = R.string.use_disk_shader_cache, - descriptionId = R.string.use_disk_shader_cache_description - ) - ) - put( - SwitchSetting( - BooleanSetting.RENDERER_FORCE_MAX_CLOCK, - titleId = R.string.renderer_force_max_clock, - descriptionId = R.string.renderer_force_max_clock_description - ) - ) - put( - SwitchSetting( - BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS, - titleId = R.string.renderer_asynchronous_shaders, - descriptionId = R.string.renderer_asynchronous_shaders_description - ) - ) - put( - SwitchSetting( - BooleanSetting.RENDERER_REACTIVE_FLUSHING, - titleId = R.string.renderer_reactive_flushing, - descriptionId = R.string.renderer_reactive_flushing_description - ) - ) - put( - SingleChoiceSetting( - IntSetting.MAX_ANISOTROPY, - titleId = R.string.anisotropic_filtering, - descriptionId = R.string.anisotropic_filtering_description, - choicesId = R.array.anisoEntries, - valuesId = R.array.anisoValues - ) - ) - put( - SingleChoiceSetting( - IntSetting.AUDIO_OUTPUT_ENGINE, - titleId = R.string.audio_output_engine, - choicesId = R.array.outputEngineEntries, - valuesId = R.array.outputEngineValues - ) - ) - put( - SliderSetting( - ByteSetting.AUDIO_VOLUME, - titleId = R.string.audio_volume, - descriptionId = R.string.audio_volume_description, - units = "%" - ) - ) - put( - SingleChoiceSetting( - IntSetting.RENDERER_BACKEND, - titleId = R.string.renderer_api, - choicesId = R.array.rendererApiNames, - valuesId = R.array.rendererApiValues - ) - ) - put( - SwitchSetting( - BooleanSetting.RENDERER_DEBUG, - titleId = R.string.renderer_debug, - descriptionId = R.string.renderer_debug_description - ) - ) - put( - SwitchSetting( - BooleanSetting.CPU_DEBUG_MODE, - titleId = R.string.cpu_debug_mode, - descriptionId = R.string.cpu_debug_mode_description - ) - ) - - val fastmem = object : AbstractBooleanSetting { - override fun getBoolean(needsGlobal: Boolean): Boolean = - BooleanSetting.FASTMEM.getBoolean() && - BooleanSetting.FASTMEM_EXCLUSIVES.getBoolean() - - override fun setBoolean(value: Boolean) { - BooleanSetting.FASTMEM.setBoolean(value) - BooleanSetting.FASTMEM_EXCLUSIVES.setBoolean(value) - } - - override val key: String = FASTMEM_COMBINED - override val isRuntimeModifiable: Boolean = false - override val pairedSettingKey = BooleanSetting.CPU_DEBUG_MODE.key - override val defaultValue: Boolean = true - override val isSwitchable: Boolean = true - override var global: Boolean - get() { - return BooleanSetting.FASTMEM.global && - BooleanSetting.FASTMEM_EXCLUSIVES.global - } - set(value) { - BooleanSetting.FASTMEM.global = value - BooleanSetting.FASTMEM_EXCLUSIVES.global = value - } - - override val isSaveable = true - - override fun getValueAsString(needsGlobal: Boolean): String = - getBoolean().toString() - - override fun reset() = setBoolean(defaultValue) - } - put(SwitchSetting(fastmem, R.string.fastmem)) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt deleted file mode 100644 index ea5e099ed..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.ArrayRes -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting - -class SingleChoiceSetting( - setting: AbstractSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - @ArrayRes val choicesId: Int, - @ArrayRes val valuesId: Int -) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_SINGLE_CHOICE - - fun getSelectedValue(needsGlobal: Boolean = false) = - when (setting) { - is AbstractIntSetting -> setting.getInt(needsGlobal) - else -> -1 - } - - fun setSelectedValue(value: Int) = (setting as AbstractIntSetting).setInt(value) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt deleted file mode 100644 index 6a5cdf48b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractByteSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractShortSetting -import kotlin.math.roundToInt - -class SliderSetting( - setting: AbstractSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - val min: Int = 0, - val max: Int = 100, - val units: String = "" -) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_SLIDER - - fun getSelectedValue(needsGlobal: Boolean = false) = - when (setting) { - is AbstractByteSetting -> setting.getByte(needsGlobal).toInt() - is AbstractShortSetting -> setting.getShort(needsGlobal).toInt() - is AbstractIntSetting -> setting.getInt(needsGlobal) - is AbstractFloatSetting -> setting.getFloat(needsGlobal).roundToInt() - else -> -1 - } - - fun setSelectedValue(value: Int) = - when (setting) { - is AbstractByteSetting -> setting.setByte(value.toByte()) - is AbstractShortSetting -> setting.setShort(value.toShort()) - is AbstractFloatSetting -> setting.setFloat(value.toFloat()) - else -> (setting as AbstractIntSetting).setInt(value) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringInputSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringInputSetting.kt deleted file mode 100644 index 1eb999416..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringInputSetting.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting - -class StringInputSetting( - setting: AbstractStringSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "" -) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_STRING_INPUT - - fun getSelectedValue(needsGlobal: Boolean = false) = setting.getValueAsString(needsGlobal) - - fun setSelectedValue(selection: String) = - (setting as AbstractStringSetting).setString(selection) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt deleted file mode 100644 index 5260ff4dc..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting - -class StringSingleChoiceSetting( - private val stringSetting: AbstractStringSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - val choices: Array, - val values: Array -) : SettingsItem(stringSetting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_STRING_SINGLE_CHOICE - - fun getValueAt(index: Int): String = - if (index >= 0 && index < values.size) values[index] else "" - - fun getSelectedValue(needsGlobal: Boolean = false) = stringSetting.getString(needsGlobal) - fun setSelectedValue(value: String) = stringSetting.setString(value) - - val selectedValueIndex: Int - get() { - for (i in values.indices) { - if (values[i] == getSelectedValue()) { - return i - } - } - return -1 - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt deleted file mode 100644 index c722393dd..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.Settings - -class SubmenuSetting( - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "", - @DrawableRes val iconId: Int = 0, - val menuKey: Settings.MenuTag -) : SettingsItem(emptySetting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_SUBMENU -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt deleted file mode 100644 index 4984bf52e..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.model.view - -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting - -class SwitchSetting( - setting: AbstractSetting, - @StringRes titleId: Int = 0, - titleString: String = "", - @StringRes descriptionId: Int = 0, - descriptionString: String = "" -) : SettingsItem(setting, titleId, titleString, descriptionId, descriptionString) { - override val type = TYPE_SWITCH - - fun getIsChecked(needsGlobal: Boolean = false): Boolean { - return when (setting) { - is AbstractIntSetting -> setting.getInt(needsGlobal) == 1 - is AbstractBooleanSetting -> setting.getBoolean(needsGlobal) - else -> false - } - } - - fun setChecked(value: Boolean) { - when (setting) { - is AbstractIntSetting -> setting.setInt(if (value) 1 else 0) - is AbstractBooleanSetting -> setting.setBoolean(value) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt deleted file mode 100644 index 16a1d0504..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputDialogFragment.kt +++ /dev/null @@ -1,300 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.app.Dialog -import android.graphics.drawable.Animatable2 -import android.graphics.drawable.AnimatedVectorDrawable -import android.graphics.drawable.Drawable -import android.os.Bundle -import android.view.InputDevice -import android.view.KeyEvent -import android.view.LayoutInflater -import android.view.MotionEvent -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogMappingBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting -import org.yuzu.yuzu_emu.utils.InputHandler -import org.yuzu.yuzu_emu.utils.ParamPackage - -class InputDialogFragment : DialogFragment() { - private var inputAccepted = false - - private var position: Int = 0 - - private lateinit var inputSetting: InputSetting - - private lateinit var binding: DialogMappingBinding - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (settingsViewModel.clickedItem == null) dismiss() - - position = requireArguments().getInt(POSITION) - - InputHandler.updateControllerData() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - inputSetting = settingsViewModel.clickedItem as InputSetting - binding = DialogMappingBinding.inflate(layoutInflater) - - val builder = MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(android.R.string.cancel) { _, _ -> - NativeInput.stopMapping() - dismiss() - } - .setView(binding.root) - - val playButtonMapAnimation = { twoDirections: Boolean -> - val stickAnimation: AnimatedVectorDrawable - val buttonAnimation: AnimatedVectorDrawable - binding.imageStickAnimation.apply { - val anim = if (twoDirections) { - R.drawable.stick_two_direction_anim - } else { - R.drawable.stick_one_direction_anim - } - setBackgroundResource(anim) - stickAnimation = background as AnimatedVectorDrawable - } - binding.imageButtonAnimation.apply { - setBackgroundResource(R.drawable.button_anim) - buttonAnimation = background as AnimatedVectorDrawable - } - stickAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - buttonAnimation.start() - } - }) - buttonAnimation.registerAnimationCallback(object : Animatable2.AnimationCallback() { - override fun onAnimationEnd(drawable: Drawable?) { - stickAnimation.start() - } - }) - stickAnimation.start() - } - - when (val setting = inputSetting) { - is AnalogInputSetting -> { - when (setting.nativeAnalog) { - NativeAnalog.LStick -> builder.setTitle( - getString(R.string.map_control, getString(R.string.left_stick)) - ) - - NativeAnalog.RStick -> builder.setTitle( - getString(R.string.map_control, getString(R.string.right_stick)) - ) - } - - builder.setMessage(R.string.stick_map_description) - - playButtonMapAnimation.invoke(true) - } - - is ModifierInputSetting -> { - builder.setTitle(getString(R.string.map_control, setting.title)) - .setMessage(R.string.button_map_description) - playButtonMapAnimation.invoke(false) - } - - is ButtonInputSetting -> { - if (setting.nativeButton == NativeButton.DUp || - setting.nativeButton == NativeButton.DDown || - setting.nativeButton == NativeButton.DLeft || - setting.nativeButton == NativeButton.DRight - ) { - builder.setTitle(getString(R.string.map_dpad_direction, setting.title)) - } else { - builder.setTitle(getString(R.string.map_control, setting.title)) - } - builder.setMessage(R.string.button_map_description) - playButtonMapAnimation.invoke(false) - } - } - - return builder.create() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.requestFocus() - view.setOnFocusChangeListener { v, hasFocus -> if (!hasFocus) v.requestFocus() } - dialog?.setOnKeyListener { _, _, keyEvent -> onKeyEvent(keyEvent) } - binding.root.setOnGenericMotionListener { _, motionEvent -> onMotionEvent(motionEvent) } - NativeInput.beginMapping(inputSetting.inputType.int) - } - - private fun onKeyEvent(event: KeyEvent): Boolean { - if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && - event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD - ) { - return false - } - - val action = when (event.action) { - KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED - KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED - else -> return false - } - val controllerData = - InputHandler.androidControllers[event.device.controllerNumber] ?: return false - NativeInput.onGamePadButtonEvent( - controllerData.getGUID(), - controllerData.getPort(), - event.keyCode, - action - ) - onInputReceived(event.device) - return true - } - - private fun onMotionEvent(event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && - event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD - ) { - return false - } - - // Temp workaround for DPads that give both axis and button input. The input system can't - // take in a specific axis direction for a binding so you lose half of the directions for a DPad. - - val controllerData = - InputHandler.androidControllers[event.device.controllerNumber] ?: return false - event.device.motionRanges.forEach { - NativeInput.onGamePadAxisEvent( - controllerData.getGUID(), - controllerData.getPort(), - it.axis, - event.getAxisValue(it.axis) - ) - onInputReceived(event.device) - } - return true - } - - private fun onInputReceived(device: InputDevice) { - val params = ParamPackage(NativeInput.getNextInput()) - if (params.has("engine") && isInputAcceptable(params) && !inputAccepted) { - inputAccepted = true - setResult(params, device) - } - } - - private fun setResult(params: ParamPackage, device: InputDevice) { - NativeInput.stopMapping() - params.set("display", "${device.name} ${params.get("port", 0)}") - when (val item = settingsViewModel.clickedItem as InputSetting) { - is ModifierInputSetting, - is ButtonInputSetting -> { - // Invert DPad up and left bindings by default - val tempSetting = inputSetting as? ButtonInputSetting - if (tempSetting != null) { - if (tempSetting.nativeButton == NativeButton.DUp || - tempSetting.nativeButton == NativeButton.DLeft && - params.has("axis") - ) { - params.set("invert", "-") - } - } - - item.setSelectedValue(params) - settingsViewModel.setAdapterItemChanged(position) - } - - is AnalogInputSetting -> { - var analogParam = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) - analogParam = adjustAnalogParam(params, analogParam, item.analogDirection.param) - - // Invert Y-Axis by default - analogParam.set("invert_y", "-") - - item.setSelectedValue(analogParam) - settingsViewModel.setReloadListAndNotifyDataset(true) - } - } - dismiss() - } - - private fun adjustAnalogParam( - inputParam: ParamPackage, - analogParam: ParamPackage, - buttonName: String - ): ParamPackage { - // The poller returned a complete axis, so set all the buttons - if (inputParam.has("axis_x") && inputParam.has("axis_y")) { - return inputParam - } - - // Check if the current configuration has either no engine or an axis binding. - // Clears out the old binding and adds one with analog_from_button. - if (!analogParam.has("engine") || analogParam.has("axis_x") || analogParam.has("axis_y")) { - analogParam.clear() - analogParam.set("engine", "analog_from_button") - } - analogParam.set(buttonName, inputParam.serialize()) - return analogParam - } - - private fun isInputAcceptable(params: ParamPackage): Boolean { - if (InputHandler.registeredControllers.size == 1) { - return true - } - - if (params.has("motion")) { - return true - } - - val currentDevice = settingsViewModel.getCurrentDeviceParams(params) - if (currentDevice.get("engine", "any") == "any") { - return true - } - - val guidMatch = params.get("guid", "") == currentDevice.get("guid", "") || - params.get("guid", "") == currentDevice.get("guid2", "") - return params.get("engine", "") == currentDevice.get("engine", "") && - guidMatch && - params.get("port", 0) == currentDevice.get("port", 0) - } - - companion object { - const val TAG = "InputDialogFragment" - - const val POSITION = "Position" - - fun newInstance( - inputMappingViewModel: SettingsViewModel, - setting: InputSetting, - position: Int - ): InputDialogFragment { - inputMappingViewModel.clickedItem = setting - val args = Bundle() - args.putInt(POSITION, position) - val fragment = InputDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt deleted file mode 100644 index 5656e9d8d..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileAdapter.kt +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.AbstractListAdapter -import org.yuzu.yuzu_emu.databinding.ListItemInputProfileBinding -import org.yuzu.yuzu_emu.viewholder.AbstractViewHolder -import org.yuzu.yuzu_emu.R - -class InputProfileAdapter(options: List) : - AbstractListAdapter>(options) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): AbstractViewHolder { - ListItemInputProfileBinding.inflate(LayoutInflater.from(parent.context), parent, false) - .also { return InputProfileViewHolder(it) } - } - - inner class InputProfileViewHolder(val binding: ListItemInputProfileBinding) : - AbstractViewHolder(binding) { - override fun bind(model: ProfileItem) { - when (model) { - is ExistingProfileItem -> { - binding.title.text = model.name - binding.buttonNew.visibility = View.GONE - binding.buttonDelete.visibility = View.VISIBLE - binding.buttonDelete.setOnClickListener { model.deleteProfile.invoke() } - binding.buttonSave.visibility = View.VISIBLE - binding.buttonSave.setOnClickListener { model.saveProfile.invoke() } - binding.buttonLoad.visibility = View.VISIBLE - binding.buttonLoad.setOnClickListener { model.loadProfile.invoke() } - } - - is NewProfileItem -> { - binding.title.text = model.name - binding.buttonNew.visibility = View.VISIBLE - binding.buttonNew.setOnClickListener { model.createNewProfile.invoke() } - binding.buttonSave.visibility = View.GONE - binding.buttonDelete.visibility = View.GONE - binding.buttonLoad.visibility = View.GONE - } - } - } - } -} - -sealed interface ProfileItem { - val name: String -} - -data class NewProfileItem( - val createNewProfile: () -> Unit -) : ProfileItem { - override val name: String = YuzuApplication.appContext.getString(R.string.create_new_profile) -} - -data class ExistingProfileItem( - override val name: String, - val deleteProfile: () -> Unit, - val saveProfile: () -> Unit, - val loadProfile: () -> Unit -) : ProfileItem diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt deleted file mode 100644 index 1bae593ae..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/InputProfileDialogFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -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.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogInputProfilesBinding -import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting -import org.yuzu.yuzu_emu.fragments.MessageDialogFragment -import org.yuzu.yuzu_emu.utils.collect - -class InputProfileDialogFragment : DialogFragment() { - private var position = 0 - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - private lateinit var binding: DialogInputProfilesBinding - - private lateinit var setting: InputProfileSetting - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - position = requireArguments().getInt(POSITION) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogInputProfilesBinding.inflate(layoutInflater) - - setting = settingsViewModel.clickedItem as InputProfileSetting - val options = mutableListOf().apply { - add( - NewProfileItem( - createNewProfile = { - NewInputProfileDialogFragment.newInstance( - settingsViewModel, - setting, - position - ).show(parentFragmentManager, NewInputProfileDialogFragment.TAG) - dismiss() - } - ) - ) - - val onActionDismiss = { - settingsViewModel.setReloadListAndNotifyDataset(true) - dismiss() - } - setting.getProfileNames().forEach { - add( - ExistingProfileItem( - it, - deleteProfile = { - settingsViewModel.setShouldShowDeleteProfileDialog(it) - }, - saveProfile = { - if (!setting.saveProfile(it)) { - Toast.makeText( - requireContext(), - R.string.failed_to_save_profile, - Toast.LENGTH_SHORT - ).show() - } - onActionDismiss.invoke() - }, - loadProfile = { - if (!setting.loadProfile(it)) { - Toast.makeText( - requireContext(), - R.string.failed_to_load_profile, - Toast.LENGTH_SHORT - ).show() - } - onActionDismiss.invoke() - } - ) - ) - } - } - binding.listProfiles.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = InputProfileAdapter(options) - } - - return MaterialAlertDialogBuilder(requireContext()) - .setView(binding.root) - .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) - - settingsViewModel.shouldShowDeleteProfileDialog.collect(viewLifecycleOwner) { - if (it.isNotEmpty()) { - MessageDialogFragment.newInstance( - activity = requireActivity(), - titleId = R.string.delete_input_profile, - descriptionId = R.string.delete_input_profile_description, - positiveAction = { - setting.deleteProfile(it) - settingsViewModel.setReloadListAndNotifyDataset(true) - }, - negativeAction = {}, - negativeButtonTitleId = android.R.string.cancel - ).show(parentFragmentManager, MessageDialogFragment.TAG) - settingsViewModel.setShouldShowDeleteProfileDialog("") - dismiss() - } - } - } - - companion object { - const val TAG = "InputProfileDialogFragment" - - const val POSITION = "Position" - - fun newInstance( - settingsViewModel: SettingsViewModel, - profileSetting: InputProfileSetting, - position: Int - ): InputProfileDialogFragment { - settingsViewModel.clickedItem = profileSetting - - val args = Bundle() - args.putInt(POSITION, position) - val fragment = InputProfileDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt deleted file mode 100644 index 6e52bea80..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/NewInputProfileDialogFragment.kt +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.app.Dialog -import android.os.Bundle -import android.widget.Toast -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding -import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting -import org.yuzu.yuzu_emu.R - -class NewInputProfileDialogFragment : DialogFragment() { - private var position = 0 - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - private lateinit var binding: DialogEditTextBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - position = requireArguments().getInt(POSITION) - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogEditTextBinding.inflate(layoutInflater) - - val setting = settingsViewModel.clickedItem as InputProfileSetting - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.enter_profile_name) - .setPositiveButton(android.R.string.ok) { _, _ -> - val profileName = binding.editText.text.toString() - if (!setting.isProfileNameValid(profileName)) { - Toast.makeText( - requireContext(), - R.string.invalid_profile_name, - Toast.LENGTH_SHORT - ).show() - return@setPositiveButton - } - - if (!setting.createProfile(profileName)) { - Toast.makeText( - requireContext(), - R.string.profile_name_already_exists, - Toast.LENGTH_SHORT - ).show() - } else { - settingsViewModel.setAdapterItemChanged(position) - } - } - .setNegativeButton(android.R.string.cancel, null) - .setView(binding.root) - .show() - } - - companion object { - const val TAG = "NewInputProfileDialogFragment" - - const val POSITION = "Position" - - fun newInstance( - settingsViewModel: SettingsViewModel, - profileSetting: InputProfileSetting, - position: Int - ): NewInputProfileDialogFragment { - settingsViewModel.clickedItem = profileSetting - - val args = Bundle() - args.putInt(POSITION, position) - val fragment = NewInputProfileDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt deleted file mode 100644 index 455b3b5ff..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.navArgs -import com.google.android.material.color.MaterialColors -import org.yuzu.yuzu_emu.NativeLibrary -import java.io.IOException -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile -import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment -import org.yuzu.yuzu_emu.utils.* - -class SettingsActivity : AppCompatActivity() { - private lateinit var binding: ActivitySettingsBinding - - private val args by navArgs() - - private val settingsViewModel: SettingsViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - ThemeHelper.setTheme(this) - - super.onCreate(savedInstanceState) - - binding = ActivitySettingsBinding.inflate(layoutInflater) - setContentView(binding.root) - - if (!NativeConfig.isPerGameConfigLoaded() && args.game != null) { - SettingsFile.loadCustomConfig(args.game!!) - } - settingsViewModel.game = args.game - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - navHostFragment.navController.setGraph(R.navigation.settings_navigation, intent.extras) - - WindowCompat.setDecorFitsSystemWindows(window, false) - - if (InsetsHelper.getSystemGestureType(applicationContext) != - InsetsHelper.GESTURE_NAVIGATION - ) { - binding.navigationBarShade.setBackgroundColor( - ThemeHelper.getColorWithOpacity( - MaterialColors.getColor( - binding.navigationBarShade, - com.google.android.material.R.attr.colorSurface - ), - ThemeHelper.SYSTEM_BAR_ALPHA - ) - ) - } - - settingsViewModel.shouldRecreate.collect( - this, - resetState = { settingsViewModel.setShouldRecreate(false) } - ) { if (it) recreate() } - settingsViewModel.shouldNavigateBack.collect( - this, - resetState = { settingsViewModel.setShouldNavigateBack(false) } - ) { if (it) navigateBack() } - settingsViewModel.shouldShowResetSettingsDialog.collect( - this, - resetState = { settingsViewModel.setShouldShowResetSettingsDialog(false) } - ) { - if (it) { - ResetSettingsDialogFragment().show( - supportFragmentManager, - ResetSettingsDialogFragment.TAG - ) - } - } - - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() = navigateBack() - } - ) - - setInsets() - } - - fun navigateBack() { - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - if (navHostFragment.childFragmentManager.backStackEntryCount > 0) { - navHostFragment.navController.popBackStack() - } else { - finish() - } - } - - override fun onStart() { - super.onStart() - if (!DirectoryInitialization.areDirectoriesReady) { - DirectoryInitialization.start() - } - } - - override fun onStop() { - super.onStop() - Log.info("[SettingsActivity] Settings activity stopping. Saving settings to INI...") - if (isFinishing) { - NativeInput.reloadInputDevices() - NativeLibrary.applySettings() - if (args.game == null) { - NativeConfig.saveGlobalConfig() - } else if (NativeConfig.isPerGameConfigLoaded()) { - NativeLibrary.logSettings() - NativeConfig.savePerGameConfig() - NativeConfig.unloadPerGameConfig() - } - } - } - - fun onSettingsReset() { - // Delete settings file because the user may have changed values that do not exist in the UI - if (args.game == null) { - NativeConfig.unloadGlobalConfig() - val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG) - if (!settingsFile.delete()) { - throw IOException("Failed to delete $settingsFile") - } - NativeConfig.initializeGlobalConfig() - } else { - NativeConfig.unloadPerGameConfig() - val settingsFile = SettingsFile.getCustomSettingsFile(args.game!!) - if (!settingsFile.delete()) { - throw IOException("Failed to delete $settingsFile") - } - } - - Toast.makeText( - applicationContext, - getString(R.string.settings_reset), - Toast.LENGTH_LONG - ).show() - finish() - } - - private fun setInsets() { - ViewCompat.setOnApplyWindowInsetsListener( - binding.navigationBarShade - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - - // The only situation where we care to have a nav bar shade is when it's at the bottom - // of the screen where scrolling list elements can go behind it. - val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams - mlpNavShade.height = barInsets.bottom - binding.navigationBarShade.layoutParams = mlpNavShade - - windowInsets - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt deleted file mode 100644 index 500ac6e66..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt +++ /dev/null @@ -1,434 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.content.Context -import android.icu.util.Calendar -import android.icu.util.TimeZone -import android.text.format.DateFormat -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.fragment.app.Fragment -import androidx.lifecycle.ViewModelProvider -import androidx.navigation.findNavController -import androidx.recyclerview.widget.AsyncDifferConfig -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.google.android.material.datepicker.MaterialDatePicker -import com.google.android.material.timepicker.MaterialTimePicker -import com.google.android.material.timepicker.TimeFormat -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.SettingsNavigationDirections -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding -import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding -import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.AnalogDirection -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting -import org.yuzu.yuzu_emu.features.settings.model.view.* -import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* -import org.yuzu.yuzu_emu.utils.ParamPackage - -class SettingsAdapter( - private val fragment: Fragment, - private val context: Context -) : ListAdapter( - AsyncDifferConfig.Builder(DiffCallback()).build() -) { - private val settingsViewModel: SettingsViewModel - get() = ViewModelProvider(fragment.requireActivity())[SettingsViewModel::class.java] - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { - val inflater = LayoutInflater.from(parent.context) - return when (viewType) { - SettingsItem.TYPE_HEADER -> { - HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_SWITCH -> { - SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { - SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_SLIDER -> { - SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_SUBMENU -> { - SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_DATETIME_SETTING -> { - DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_RUNNABLE -> { - RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_INPUT -> { - InputViewHolder(ListItemSettingInputBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_INT_SINGLE_CHOICE -> { - SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_INPUT_PROFILE -> { - InputProfileViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - SettingsItem.TYPE_STRING_INPUT -> { - StringInputViewHolder(ListItemSettingBinding.inflate(inflater), this) - } - - else -> { - HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) - } - } - } - - override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { - holder.bind(currentList[position]) - } - - override fun getItemCount(): Int = currentList.size - - override fun getItemViewType(position: Int): Int { - return currentList[position].type - } - - fun onBooleanClick(item: SwitchSetting, checked: Boolean, position: Int) { - item.setChecked(checked) - notifyItemChanged(position) - settingsViewModel.setShouldReloadSettingsList(true) - } - - fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsItem.TYPE_SINGLE_CHOICE, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - } - - fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsItem.TYPE_STRING_SINGLE_CHOICE, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - } - - fun onIntSingleChoiceClick(item: IntSingleChoiceSetting, position: Int) { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsItem.TYPE_INT_SINGLE_CHOICE, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - } - - fun onDateTimeClick(item: DateTimeSetting, position: Int) { - val storedTime = item.getValue() * 1000 - - // Helper to extract hour and minute from epoch time - val calendar: Calendar = Calendar.getInstance() - calendar.timeInMillis = storedTime - calendar.timeZone = TimeZone.getTimeZone("UTC") - - var timeFormat: Int = TimeFormat.CLOCK_12H - if (DateFormat.is24HourFormat(context)) { - timeFormat = TimeFormat.CLOCK_24H - } - - val datePicker: MaterialDatePicker = MaterialDatePicker.Builder.datePicker() - .setSelection(storedTime) - .setTitleText(R.string.select_rtc_date) - .build() - val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() - .setTimeFormat(timeFormat) - .setHour(calendar.get(Calendar.HOUR_OF_DAY)) - .setMinute(calendar.get(Calendar.MINUTE)) - .setTitleText(R.string.select_rtc_time) - .build() - - datePicker.addOnPositiveButtonClickListener { - timePicker.show( - fragment.childFragmentManager, - "TimePicker" - ) - } - timePicker.addOnPositiveButtonClickListener { - var epochTime: Long = datePicker.selection!! / 1000 - epochTime += timePicker.hour.toLong() * 60 * 60 - epochTime += timePicker.minute.toLong() * 60 - if (item.getValue() != epochTime) { - notifyItemChanged(position) - item.setValue(epochTime) - } - } - datePicker.show( - fragment.childFragmentManager, - "DatePicker" - ) - } - - fun onSliderClick(item: SliderSetting, position: Int) { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsItem.TYPE_SLIDER, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - } - - fun onSubmenuClick(item: SubmenuSetting) { - val action = SettingsNavigationDirections.actionGlobalSettingsFragment(item.menuKey, null) - fragment.view?.findNavController()?.navigate(action) - } - - fun onInputProfileClick(item: InputProfileSetting, position: Int) { - InputProfileDialogFragment.newInstance( - settingsViewModel, - item, - position - ).show(fragment.childFragmentManager, InputProfileDialogFragment.TAG) - } - - fun onInputClick(item: InputSetting, position: Int) { - InputDialogFragment.newInstance( - settingsViewModel, - item, - position - ).show(fragment.childFragmentManager, InputDialogFragment.TAG) - } - - fun onInputOptionsClick(anchor: View, item: InputSetting, position: Int) { - val popup = PopupMenu(context, anchor) - popup.menuInflater.inflate(R.menu.menu_input_options, popup.menu) - - popup.menu.apply { - val invertAxis = findItem(R.id.invert_axis) - val invertButton = findItem(R.id.invert_button) - val toggleButton = findItem(R.id.toggle_button) - val turboButton = findItem(R.id.turbo_button) - val setThreshold = findItem(R.id.set_threshold) - val toggleAxis = findItem(R.id.toggle_axis) - when (item) { - is AnalogInputSetting -> { - val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) - - invertAxis.isVisible = true - invertAxis.isCheckable = true - invertAxis.isChecked = when (item.analogDirection) { - AnalogDirection.Left, AnalogDirection.Right -> { - params.get("invert_x", "+") == "-" - } - - AnalogDirection.Up, AnalogDirection.Down -> { - params.get("invert_y", "+") == "-" - } - } - invertAxis.setOnMenuItemClickListener { - if (item.analogDirection == AnalogDirection.Left || - item.analogDirection == AnalogDirection.Right - ) { - val invertValue = params.get("invert_x", "+") == "-" - val invertString = if (invertValue) "+" else "-" - params.set("invert_x", invertString) - } else if ( - item.analogDirection == AnalogDirection.Up || - item.analogDirection == AnalogDirection.Down - ) { - val invertValue = params.get("invert_y", "+") == "-" - val invertString = if (invertValue) "+" else "-" - params.set("invert_y", invertString) - } - true - } - - popup.setOnDismissListener { - NativeInput.setStickParam(item.playerIndex, item.nativeAnalog, params) - settingsViewModel.setDatasetChanged(true) - } - } - - is ButtonInputSetting -> { - val params = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) - if (params.has("code") || params.has("button") || params.has("hat")) { - val buttonInvert = params.get("inverted", false) - invertButton.isVisible = true - invertButton.isCheckable = true - invertButton.isChecked = buttonInvert - invertButton.setOnMenuItemClickListener { - params.set("inverted", !buttonInvert) - true - } - - val toggle = params.get("toggle", false) - toggleButton.isVisible = true - toggleButton.isCheckable = true - toggleButton.isChecked = toggle - toggleButton.setOnMenuItemClickListener { - params.set("toggle", !toggle) - true - } - - val turbo = params.get("turbo", false) - turboButton.isVisible = true - turboButton.isCheckable = true - turboButton.isChecked = turbo - turboButton.setOnMenuItemClickListener { - params.set("turbo", !turbo) - true - } - } else if (params.has("axis")) { - val axisInvert = params.get("invert", "+") == "-" - invertAxis.isVisible = true - invertAxis.isCheckable = true - invertAxis.isChecked = axisInvert - invertAxis.setOnMenuItemClickListener { - params.set("invert", if (!axisInvert) "-" else "+") - true - } - - val buttonInvert = params.get("inverted", false) - invertButton.isVisible = true - invertButton.isCheckable = true - invertButton.isChecked = buttonInvert - invertButton.setOnMenuItemClickListener { - params.set("inverted", !buttonInvert) - true - } - - setThreshold.isVisible = true - val thresholdSetting = object : AbstractIntSetting { - override val key = "" - - override fun getInt(needsGlobal: Boolean): Int = - (params.get("threshold", 0.5f) * 100).toInt() - - override fun setInt(value: Int) { - params.set("threshold", value.toFloat() / 100) - NativeInput.setButtonParam( - item.playerIndex, - item.nativeButton, - params - ) - } - - override val defaultValue = 50 - - override fun getValueAsString(needsGlobal: Boolean): String = - getInt(needsGlobal).toString() - - override fun reset() = setInt(defaultValue) - } - setThreshold.setOnMenuItemClickListener { - onSliderClick( - SliderSetting(thresholdSetting, R.string.set_threshold), - position - ) - true - } - - val axisToggle = params.get("toggle", false) - toggleAxis.isVisible = true - toggleAxis.isCheckable = true - toggleAxis.isChecked = axisToggle - toggleAxis.setOnMenuItemClickListener { - params.set("toggle", !axisToggle) - true - } - } - - popup.setOnDismissListener { - NativeInput.setButtonParam(item.playerIndex, item.nativeButton, params) - settingsViewModel.setAdapterItemChanged(position) - } - } - - is ModifierInputSetting -> { - val stickParams = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) - val modifierParams = ParamPackage(stickParams.get("modifier", "")) - - val invert = modifierParams.get("inverted", false) - invertButton.isVisible = true - invertButton.isCheckable = true - invertButton.isChecked = invert - invertButton.setOnMenuItemClickListener { - modifierParams.set("inverted", !invert) - stickParams.set("modifier", modifierParams.serialize()) - true - } - - val toggle = modifierParams.get("toggle", false) - toggleButton.isVisible = true - toggleButton.isCheckable = true - toggleButton.isChecked = toggle - toggleButton.setOnMenuItemClickListener { - modifierParams.set("toggle", !toggle) - stickParams.set("modifier", modifierParams.serialize()) - true - } - - popup.setOnDismissListener { - NativeInput.setStickParam( - item.playerIndex, - item.nativeAnalog, - stickParams - ) - settingsViewModel.setAdapterItemChanged(position) - } - } - } - } - popup.show() - } - - fun onStringInputClick(item: StringInputSetting, position: Int) { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsItem.TYPE_STRING_INPUT, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - } - - fun onLongClick(item: SettingsItem, position: Int): Boolean { - SettingsDialogFragment.newInstance( - settingsViewModel, - item, - SettingsDialogFragment.TYPE_RESET_SETTING, - position - ).show(fragment.childFragmentManager, SettingsDialogFragment.TAG) - - return true - } - - fun onClearClick(item: SettingsItem, position: Int) { - item.setting.global = true - notifyItemChanged(position) - settingsViewModel.setShouldReloadSettingsList(true) - } - - private class DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { - return oldItem.setting.key == newItem.setting.key - } - - override fun areContentsTheSame(oldItem: SettingsItem, newItem: SettingsItem): Boolean { - return oldItem.setting.key == newItem.setting.key - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt deleted file mode 100644 index 7f562a1f4..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsDialogFragment.kt +++ /dev/null @@ -1,301 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding -import org.yuzu.yuzu_emu.databinding.DialogSliderBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.AnalogDirection -import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.IntSingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting -import org.yuzu.yuzu_emu.features.settings.model.view.StringInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting -import org.yuzu.yuzu_emu.utils.ParamPackage -import org.yuzu.yuzu_emu.utils.collect - -class SettingsDialogFragment : DialogFragment(), DialogInterface.OnClickListener { - private var type = 0 - private var position = 0 - - private var defaultCancelListener = - DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } - - private val settingsViewModel: SettingsViewModel by activityViewModels() - - private lateinit var sliderBinding: DialogSliderBinding - private lateinit var stringInputBinding: DialogEditTextBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - type = requireArguments().getInt(TYPE) - position = requireArguments().getInt(POSITION) - - if (settingsViewModel.clickedItem == null) dismiss() - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return when (type) { - TYPE_RESET_SETTING -> { - MaterialAlertDialogBuilder(requireContext()) - .setMessage(R.string.reset_setting_confirmation) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - when (val item = settingsViewModel.clickedItem) { - is AnalogInputSetting -> { - val stickParam = NativeInput.getStickParam( - item.playerIndex, - item.nativeAnalog - ) - if (stickParam.get("engine", "") == "analog_from_button") { - when (item.analogDirection) { - AnalogDirection.Up -> stickParam.erase("up") - AnalogDirection.Down -> stickParam.erase("down") - AnalogDirection.Left -> stickParam.erase("left") - AnalogDirection.Right -> stickParam.erase("right") - } - NativeInput.setStickParam( - item.playerIndex, - item.nativeAnalog, - stickParam - ) - settingsViewModel.setAdapterItemChanged(position) - } else { - NativeInput.setStickParam( - item.playerIndex, - item.nativeAnalog, - ParamPackage() - ) - settingsViewModel.setDatasetChanged(true) - } - } - - is ButtonInputSetting -> { - NativeInput.setButtonParam( - item.playerIndex, - item.nativeButton, - ParamPackage() - ) - settingsViewModel.setAdapterItemChanged(position) - } - - else -> { - settingsViewModel.clickedItem!!.setting.reset() - settingsViewModel.setAdapterItemChanged(position) - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .create() - } - - SettingsItem.TYPE_SINGLE_CHOICE -> { - val item = settingsViewModel.clickedItem as SingleChoiceSetting - val value = getSelectionForSingleChoiceValue(item) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setSingleChoiceItems(item.choicesId, value, this) - .create() - } - - SettingsItem.TYPE_SLIDER -> { - sliderBinding = DialogSliderBinding.inflate(layoutInflater) - val item = settingsViewModel.clickedItem as SliderSetting - - settingsViewModel.setSliderTextValue(item.getSelectedValue().toFloat(), item.units) - sliderBinding.slider.apply { - valueFrom = item.min.toFloat() - valueTo = item.max.toFloat() - value = settingsViewModel.sliderProgress.value.toFloat() - addOnChangeListener { _: Slider, value: Float, _: Boolean -> - settingsViewModel.setSliderTextValue(value, item.units) - } - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setView(sliderBinding.root) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, defaultCancelListener) - .create() - } - - SettingsItem.TYPE_STRING_INPUT -> { - stringInputBinding = DialogEditTextBinding.inflate(layoutInflater) - val item = settingsViewModel.clickedItem as StringInputSetting - stringInputBinding.editText.setText(item.getSelectedValue()) - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setView(stringInputBinding.root) - .setPositiveButton(android.R.string.ok, this) - .setNegativeButton(android.R.string.cancel, defaultCancelListener) - .create() - } - - SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { - val item = settingsViewModel.clickedItem as StringSingleChoiceSetting - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) - .create() - } - - SettingsItem.TYPE_INT_SINGLE_CHOICE -> { - val item = settingsViewModel.clickedItem as IntSingleChoiceSetting - MaterialAlertDialogBuilder(requireContext()) - .setTitle(item.title) - .setSingleChoiceItems(item.choices, item.selectedValueIndex, this) - .create() - } - - else -> super.onCreateDialog(savedInstanceState) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return when (type) { - SettingsItem.TYPE_SLIDER -> sliderBinding.root - SettingsItem.TYPE_STRING_INPUT -> stringInputBinding.root - else -> super.onCreateView(inflater, container, savedInstanceState) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - when (type) { - SettingsItem.TYPE_SLIDER -> { - settingsViewModel.sliderTextValue.collect(viewLifecycleOwner) { - sliderBinding.textValue.text = it - } - settingsViewModel.sliderProgress.collect(viewLifecycleOwner) { - sliderBinding.slider.value = it.toFloat() - } - } - } - } - - override fun onClick(dialog: DialogInterface, which: Int) { - when (settingsViewModel.clickedItem) { - is SingleChoiceSetting -> { - val scSetting = settingsViewModel.clickedItem as SingleChoiceSetting - val value = getValueForSingleChoiceSelection(scSetting, which) - scSetting.setSelectedValue(value) - } - - is StringSingleChoiceSetting -> { - val scSetting = settingsViewModel.clickedItem as StringSingleChoiceSetting - val value = scSetting.getValueAt(which) - scSetting.setSelectedValue(value) - } - - is IntSingleChoiceSetting -> { - val scSetting = settingsViewModel.clickedItem as IntSingleChoiceSetting - val value = scSetting.getValueAt(which) - scSetting.setSelectedValue(value) - } - - is SliderSetting -> { - val sliderSetting = settingsViewModel.clickedItem as SliderSetting - sliderSetting.setSelectedValue(settingsViewModel.sliderProgress.value) - } - - is StringInputSetting -> { - val stringInputSetting = settingsViewModel.clickedItem as StringInputSetting - stringInputSetting.setSelectedValue( - (stringInputBinding.editText.text ?: "").toString() - ) - } - } - closeDialog() - } - - private fun closeDialog() { - settingsViewModel.setAdapterItemChanged(position) - settingsViewModel.clickedItem = null - settingsViewModel.setSliderProgress(-1f) - dismiss() - } - - private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { - val valuesId = item.valuesId - return if (valuesId > 0) { - val valuesArray = requireContext().resources.getIntArray(valuesId) - valuesArray[which] - } else { - which - } - } - - private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { - val value = item.getSelectedValue() - val valuesId = item.valuesId - if (valuesId > 0) { - val valuesArray = requireContext().resources.getIntArray(valuesId) - for (index in valuesArray.indices) { - val current = valuesArray[index] - if (current == value) { - return index - } - } - } else { - return value - } - return -1 - } - - companion object { - const val TAG = "SettingsDialogFragment" - - const val TYPE_RESET_SETTING = -1 - - const val TITLE = "Title" - const val TYPE = "Type" - const val POSITION = "Position" - - fun newInstance( - settingsViewModel: SettingsViewModel, - clickedItem: SettingsItem, - type: Int, - position: Int - ): SettingsDialogFragment { - when (type) { - SettingsItem.TYPE_HEADER, - SettingsItem.TYPE_SWITCH, - SettingsItem.TYPE_SUBMENU, - SettingsItem.TYPE_DATETIME_SETTING, - SettingsItem.TYPE_RUNNABLE -> - throw IllegalArgumentException("[SettingsDialogFragment] Incompatible type!") - - SettingsItem.TYPE_SLIDER -> settingsViewModel.setSliderProgress( - (clickedItem as SliderSetting).getSelectedValue().toFloat() - ) - } - settingsViewModel.clickedItem = clickedItem - - val args = Bundle() - args.putInt(TYPE, type) - args.putInt(POSITION, position) - val fragment = SettingsDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt deleted file mode 100644 index ec16f16c4..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.fragments.MessageDialogFragment -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect - -class SettingsFragment : Fragment() { - private lateinit var presenter: SettingsFragmentPresenter - private var settingsAdapter: SettingsAdapter? = null - - private var _binding: FragmentSettingsBinding? = null - private val binding get() = _binding!! - - private val args by navArgs() - - private val settingsViewModel: SettingsViewModel 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) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - - val playerIndex = getPlayerIndex() - if (playerIndex != -1) { - NativeInput.loadInputProfiles() - NativeInput.reloadInputDevices() - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsBinding.inflate(layoutInflater) - return binding.root - } - - @SuppressLint("NotifyDataSetChanged") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settingsAdapter = SettingsAdapter(this, requireContext()) - presenter = SettingsFragmentPresenter( - settingsViewModel, - settingsAdapter!!, - args.menuTag - ) - - binding.toolbarSettingsLayout.title = if (args.menuTag == Settings.MenuTag.SECTION_ROOT && - args.game != null - ) { - args.game!!.title - } else { - when (args.menuTag) { - Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> Settings.getPlayerString(1) - Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> Settings.getPlayerString(2) - Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> Settings.getPlayerString(3) - Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> Settings.getPlayerString(4) - Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> Settings.getPlayerString(5) - Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> Settings.getPlayerString(6) - Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> Settings.getPlayerString(7) - Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> Settings.getPlayerString(8) - else -> getString(args.menuTag.titleId) - } - } - binding.listSettings.apply { - adapter = settingsAdapter - layoutManager = LinearLayoutManager(requireContext()) - } - - binding.toolbarSettings.setNavigationOnClickListener { - settingsViewModel.setShouldNavigateBack(true) - } - - settingsViewModel.shouldReloadSettingsList.collect( - viewLifecycleOwner, - resetState = { settingsViewModel.setShouldReloadSettingsList(false) } - ) { if (it) presenter.loadSettingsList() } - settingsViewModel.adapterItemChanged.collect( - viewLifecycleOwner, - resetState = { settingsViewModel.setAdapterItemChanged(-1) } - ) { if (it != -1) settingsAdapter?.notifyItemChanged(it) } - settingsViewModel.datasetChanged.collect( - viewLifecycleOwner, - resetState = { settingsViewModel.setDatasetChanged(false) } - ) { if (it) settingsAdapter?.notifyDataSetChanged() } - settingsViewModel.reloadListAndNotifyDataset.collect( - viewLifecycleOwner, - resetState = { settingsViewModel.setReloadListAndNotifyDataset(false) } - ) { if (it) presenter.loadSettingsList(true) } - settingsViewModel.shouldShowResetInputDialog.collect( - viewLifecycleOwner, - resetState = { settingsViewModel.setShouldShowResetInputDialog(false) } - ) { - if (it) { - MessageDialogFragment.newInstance( - activity = requireActivity(), - titleId = R.string.reset_mapping, - descriptionId = R.string.reset_mapping_description, - positiveAction = { - NativeInput.resetControllerMappings(getPlayerIndex()) - settingsViewModel.setReloadListAndNotifyDataset(true) - }, - negativeAction = {} - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - } - - if (args.menuTag == Settings.MenuTag.SECTION_ROOT) { - binding.toolbarSettings.inflateMenu(R.menu.menu_settings) - binding.toolbarSettings.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_search -> { - view.findNavController() - .navigate(R.id.action_settingsFragment_to_settingsSearchFragment) - true - } - - else -> false - } - } - } - - presenter.onViewCreated() - - setInsets() - } - - private fun getPlayerIndex(): Int = - when (args.menuTag) { - Settings.MenuTag.SECTION_INPUT_PLAYER_ONE -> 0 - Settings.MenuTag.SECTION_INPUT_PLAYER_TWO -> 1 - Settings.MenuTag.SECTION_INPUT_PLAYER_THREE -> 2 - Settings.MenuTag.SECTION_INPUT_PLAYER_FOUR -> 3 - Settings.MenuTag.SECTION_INPUT_PLAYER_FIVE -> 4 - Settings.MenuTag.SECTION_INPUT_PLAYER_SIX -> 5 - Settings.MenuTag.SECTION_INPUT_PLAYER_SEVEN -> 6 - Settings.MenuTag.SECTION_INPUT_PLAYER_EIGHT -> 7 - else -> -1 - } - - private fun setInsets() { - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.listSettings.updateMargins(left = leftInsets, right = rightInsets) - binding.listSettings.updatePadding(bottom = barInsets.bottom) - - binding.appbarSettings.updateMargins(left = leftInsets, right = rightInsets) - windowInsets - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt deleted file mode 100644 index 3ea5f5008..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt +++ /dev/null @@ -1,975 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.annotation.SuppressLint -import android.os.Build -import android.widget.Toast -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.model.AnalogDirection -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex -import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.ByteSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.features.settings.model.LongSetting -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.features.settings.model.Settings.MenuTag -import org.yuzu.yuzu_emu.features.settings.model.ShortSetting -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import org.yuzu.yuzu_emu.features.settings.model.view.* -import org.yuzu.yuzu_emu.utils.InputHandler -import org.yuzu.yuzu_emu.utils.NativeConfig - -class SettingsFragmentPresenter( - private val settingsViewModel: SettingsViewModel, - private val adapter: SettingsAdapter, - private var menuTag: MenuTag -) { - private var settingsList = ArrayList() - - private val context get() = YuzuApplication.appContext - - // Extension for altering settings list based on each setting's properties - fun ArrayList.add(key: String) { - val item = SettingsItem.settingsItems[key]!! - if (settingsViewModel.game != null && !item.setting.isSwitchable) { - return - } - - if (!NativeConfig.isPerGameConfigLoaded() && !NativeLibrary.isRunning()) { - item.setting.global = true - } - - val pairedSettingKey = item.setting.pairedSettingKey - if (pairedSettingKey.isNotEmpty()) { - val pairedSettingValue = NativeConfig.getBoolean( - pairedSettingKey, - if (NativeLibrary.isRunning() && !NativeConfig.isPerGameConfigLoaded()) { - !NativeConfig.usingGlobal(pairedSettingKey) - } else { - NativeConfig.usingGlobal(pairedSettingKey) - } - ) - if (!pairedSettingValue) return - } - add(item) - } - - // Allows you to show/hide abstract settings based on the paired setting key - fun ArrayList.addAbstract(item: SettingsItem) { - val pairedSettingKey = item.setting.pairedSettingKey - if (pairedSettingKey.isNotEmpty()) { - val pairedSettingsItem = - this.firstOrNull { it.setting.key == pairedSettingKey } ?: return - val pairedSetting = pairedSettingsItem.setting as AbstractBooleanSetting - if (!pairedSetting.getBoolean(!NativeConfig.isPerGameConfigLoaded())) return - } - add(item) - } - - fun onViewCreated() { - loadSettingsList() - } - - @SuppressLint("NotifyDataSetChanged") - fun loadSettingsList(notifyDataSetChanged: Boolean = false) { - val sl = ArrayList() - when (menuTag) { - MenuTag.SECTION_ROOT -> addConfigSettings(sl) - MenuTag.SECTION_SYSTEM -> addSystemSettings(sl) - MenuTag.SECTION_RENDERER -> addGraphicsSettings(sl) - MenuTag.SECTION_AUDIO -> addAudioSettings(sl) - MenuTag.SECTION_INPUT -> addInputSettings(sl) - MenuTag.SECTION_INPUT_PLAYER_ONE -> addInputPlayer(sl, 0) - MenuTag.SECTION_INPUT_PLAYER_TWO -> addInputPlayer(sl, 1) - MenuTag.SECTION_INPUT_PLAYER_THREE -> addInputPlayer(sl, 2) - MenuTag.SECTION_INPUT_PLAYER_FOUR -> addInputPlayer(sl, 3) - MenuTag.SECTION_INPUT_PLAYER_FIVE -> addInputPlayer(sl, 4) - MenuTag.SECTION_INPUT_PLAYER_SIX -> addInputPlayer(sl, 5) - MenuTag.SECTION_INPUT_PLAYER_SEVEN -> addInputPlayer(sl, 6) - MenuTag.SECTION_INPUT_PLAYER_EIGHT -> addInputPlayer(sl, 7) - MenuTag.SECTION_THEME -> addThemeSettings(sl) - MenuTag.SECTION_DEBUG -> addDebugSettings(sl) - } - settingsList = sl - adapter.submitList(settingsList) { - if (notifyDataSetChanged) { - adapter.notifyDataSetChanged() - } - } - } - - private fun addConfigSettings(sl: ArrayList) { - sl.apply { - add( - SubmenuSetting( - titleId = R.string.preferences_system, - descriptionId = R.string.preferences_system_description, - iconId = R.drawable.ic_system_settings, - menuKey = MenuTag.SECTION_SYSTEM - ) - ) - add( - SubmenuSetting( - titleId = R.string.preferences_graphics, - descriptionId = R.string.preferences_graphics_description, - iconId = R.drawable.ic_graphics, - menuKey = MenuTag.SECTION_RENDERER - ) - ) - add( - SubmenuSetting( - titleId = R.string.preferences_audio, - descriptionId = R.string.preferences_audio_description, - iconId = R.drawable.ic_audio, - menuKey = MenuTag.SECTION_AUDIO - ) - ) - add( - SubmenuSetting( - titleId = R.string.preferences_debug, - descriptionId = R.string.preferences_debug_description, - iconId = R.drawable.ic_code, - menuKey = MenuTag.SECTION_DEBUG - ) - ) - add( - RunnableSetting( - titleId = R.string.reset_to_default, - descriptionId = R.string.reset_to_default_description, - isRunnable = !NativeLibrary.isRunning(), - iconId = R.drawable.ic_restore - ) { settingsViewModel.setShouldShowResetSettingsDialog(true) } - ) - } - } - - private fun addSystemSettings(sl: ArrayList) { - sl.apply { - add(StringSetting.DEVICE_NAME.key) - add(BooleanSetting.RENDERER_USE_SPEED_LIMIT.key) - add(ShortSetting.RENDERER_SPEED_LIMIT.key) - add(BooleanSetting.USE_DOCKED_MODE.key) - add(IntSetting.REGION_INDEX.key) - add(IntSetting.LANGUAGE_INDEX.key) - add(BooleanSetting.USE_CUSTOM_RTC.key) - add(LongSetting.CUSTOM_RTC.key) - } - } - - private fun addGraphicsSettings(sl: ArrayList) { - sl.apply { - add(IntSetting.RENDERER_ACCURACY.key) - add(IntSetting.RENDERER_RESOLUTION.key) - add(IntSetting.RENDERER_VSYNC.key) - add(IntSetting.RENDERER_SCALING_FILTER.key) - add(IntSetting.FSR_SHARPENING_SLIDER.key) - add(IntSetting.RENDERER_ANTI_ALIASING.key) - add(IntSetting.MAX_ANISOTROPY.key) - add(IntSetting.RENDERER_SCREEN_LAYOUT.key) - add(IntSetting.RENDERER_ASPECT_RATIO.key) - add(IntSetting.VERTICAL_ALIGNMENT.key) - add(BooleanSetting.PICTURE_IN_PICTURE.key) - add(BooleanSetting.RENDERER_USE_DISK_SHADER_CACHE.key) - add(BooleanSetting.RENDERER_FORCE_MAX_CLOCK.key) - add(BooleanSetting.RENDERER_ASYNCHRONOUS_SHADERS.key) - add(BooleanSetting.RENDERER_REACTIVE_FLUSHING.key) - } - } - - private fun addAudioSettings(sl: ArrayList) { - sl.apply { - add(IntSetting.AUDIO_OUTPUT_ENGINE.key) - add(ByteSetting.AUDIO_VOLUME.key) - } - } - - private fun addInputSettings(sl: ArrayList) { - settingsViewModel.currentDevice = 0 - - if (NativeConfig.isPerGameConfigLoaded()) { - NativeInput.loadInputProfiles() - val profiles = NativeInput.getInputProfileNames().toMutableList() - profiles.add(0, "") - val prettyProfiles = profiles.toTypedArray() - prettyProfiles[0] = - context.getString(R.string.use_global_input_configuration) - sl.apply { - for (i in 0 until 8) { - add( - IntSingleChoiceSetting( - getPerGameProfileSetting(profiles, i), - titleString = getPlayerProfileString(i + 1), - choices = prettyProfiles, - values = IntArray(profiles.size) { it }.toTypedArray() - ) - ) - } - } - return - } - - val getConnectedIcon: (Int) -> Int = { playerIndex: Int -> - if (NativeInput.getIsConnected(playerIndex)) { - R.drawable.ic_controller - } else { - R.drawable.ic_controller_disconnected - } - } - - val inputSettings = NativeConfig.getInputSettings(true) - sl.apply { - add( - SubmenuSetting( - titleString = Settings.getPlayerString(1), - descriptionString = inputSettings[0].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_ONE, - iconId = getConnectedIcon(0) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(2), - descriptionString = inputSettings[1].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_TWO, - iconId = getConnectedIcon(1) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(3), - descriptionString = inputSettings[2].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_THREE, - iconId = getConnectedIcon(2) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(4), - descriptionString = inputSettings[3].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_FOUR, - iconId = getConnectedIcon(3) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(5), - descriptionString = inputSettings[4].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_FIVE, - iconId = getConnectedIcon(4) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(6), - descriptionString = inputSettings[5].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_SIX, - iconId = getConnectedIcon(5) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(7), - descriptionString = inputSettings[6].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_SEVEN, - iconId = getConnectedIcon(6) - ) - ) - add( - SubmenuSetting( - titleString = Settings.getPlayerString(8), - descriptionString = inputSettings[7].profileName, - menuKey = MenuTag.SECTION_INPUT_PLAYER_EIGHT, - iconId = getConnectedIcon(7) - ) - ) - } - } - - private fun getPlayerProfileString(player: Int): String = - context.getString(R.string.player_num_profile, player) - - private fun getPerGameProfileSetting( - profiles: List, - playerIndex: Int - ): AbstractIntSetting { - return object : AbstractIntSetting { - private val players - get() = NativeConfig.getInputSettings(false) - - override val key = "" - - override fun getInt(needsGlobal: Boolean): Int { - val currentProfile = players[playerIndex].profileName - profiles.forEachIndexed { i, profile -> - if (profile == currentProfile) { - return i - } - } - return 0 - } - - override fun setInt(value: Int) { - NativeInput.loadPerGameConfiguration(playerIndex, value, profiles[value]) - NativeInput.connectControllers(playerIndex) - NativeConfig.saveControlPlayerValues() - } - - override val defaultValue = 0 - - override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() - - override fun reset() = setInt(defaultValue) - - override var global = true - - override val isRuntimeModifiable = true - - override val isSaveable = true - } - } - - private fun addInputPlayer(sl: ArrayList, playerIndex: Int) { - sl.apply { - val connectedSetting = object : AbstractBooleanSetting { - override val key = "connected" - - override fun getBoolean(needsGlobal: Boolean): Boolean = - NativeInput.getIsConnected(playerIndex) - - override fun setBoolean(value: Boolean) = - NativeInput.connectControllers(playerIndex, value) - - override val defaultValue = playerIndex == 0 - - override fun getValueAsString(needsGlobal: Boolean): String = - getBoolean(needsGlobal).toString() - - override fun reset() = setBoolean(defaultValue) - } - add(SwitchSetting(connectedSetting, R.string.connected)) - - val styleTags = NativeInput.getSupportedStyleTags(playerIndex) - val npadType = object : AbstractIntSetting { - override val key = "npad_type" - override fun getInt(needsGlobal: Boolean): Int { - val styleIndex = NativeInput.getStyleIndex(playerIndex) - return styleTags.indexOfFirst { it == styleIndex } - } - - override fun setInt(value: Int) { - NativeInput.setStyleIndex(playerIndex, styleTags[value]) - settingsViewModel.setReloadListAndNotifyDataset(true) - } - - override val defaultValue = NpadStyleIndex.Fullkey.int - override fun getValueAsString(needsGlobal: Boolean): String = getInt().toString() - override fun reset() = setInt(defaultValue) - override val pairedSettingKey: String = "connected" - } - addAbstract( - IntSingleChoiceSetting( - npadType, - titleId = R.string.controller_type, - choices = styleTags.map { context.getString(it.nameId) } - .toTypedArray(), - values = IntArray(styleTags.size) { it }.toTypedArray() - ) - ) - - InputHandler.updateControllerData() - - val autoMappingSetting = object : AbstractIntSetting { - override val key = "auto_mapping_device" - - override fun getInt(needsGlobal: Boolean): Int = -1 - - override fun setInt(value: Int) { - val registeredController = InputHandler.registeredControllers[value + 1] - val displayName = registeredController.get( - "display", - context.getString(R.string.unknown) - ) - NativeInput.updateMappingsWithDefault( - playerIndex, - registeredController, - displayName - ) - Toast.makeText( - context, - context.getString(R.string.attempted_auto_map, displayName), - Toast.LENGTH_SHORT - ).show() - settingsViewModel.setReloadListAndNotifyDataset(true) - } - - override val defaultValue = -1 - - override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() - - override fun reset() = setInt(defaultValue) - - override val isRuntimeModifiable: Boolean = true - } - - val unknownString = context.getString(R.string.unknown) - val prettyAutoMappingControllerList = InputHandler.registeredControllers.mapNotNull { - val port = it.get("port", -1) - return@mapNotNull if (port == 100 || port == -1) { - null - } else { - it.get("display", unknownString) - } - }.toTypedArray() - add( - IntSingleChoiceSetting( - autoMappingSetting, - titleId = R.string.auto_map, - descriptionId = R.string.auto_map_description, - choices = prettyAutoMappingControllerList, - values = IntArray(prettyAutoMappingControllerList.size) { it }.toTypedArray() - ) - ) - - val mappingFilterSetting = object : AbstractIntSetting { - override val key = "mapping_filter" - - override fun getInt(needsGlobal: Boolean): Int = settingsViewModel.currentDevice - - override fun setInt(value: Int) { - settingsViewModel.currentDevice = value - } - - override val defaultValue = 0 - - override fun getValueAsString(needsGlobal: Boolean) = getInt().toString() - - override fun reset() = setInt(defaultValue) - - override val isRuntimeModifiable: Boolean = true - } - - val prettyControllerList = InputHandler.registeredControllers.mapNotNull { - return@mapNotNull if (it.get("port", 0) == 100) { - null - } else { - it.get("display", unknownString) - } - }.toTypedArray() - add( - IntSingleChoiceSetting( - mappingFilterSetting, - titleId = R.string.input_mapping_filter, - descriptionId = R.string.input_mapping_filter_description, - choices = prettyControllerList, - values = IntArray(prettyControllerList.size) { it }.toTypedArray() - ) - ) - - add(InputProfileSetting(playerIndex)) - add( - RunnableSetting(titleId = R.string.reset_to_default, isRunnable = true) { - settingsViewModel.setShouldShowResetInputDialog(true) - } - ) - - val styleIndex = NativeInput.getStyleIndex(playerIndex) - - // Buttons - when (styleIndex) { - NpadStyleIndex.Fullkey, - NpadStyleIndex.Handheld, - NpadStyleIndex.JoyconDual -> { - add(HeaderSetting(R.string.buttons)) - add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) - add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) - add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) - add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) - add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) - add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) - add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) - add( - ButtonInputSetting( - playerIndex, - NativeButton.Capture, - R.string.button_capture - ) - ) - } - - NpadStyleIndex.JoyconLeft -> { - add(HeaderSetting(R.string.buttons)) - add(ButtonInputSetting(playerIndex, NativeButton.Minus, R.string.button_minus)) - add( - ButtonInputSetting( - playerIndex, - NativeButton.Capture, - R.string.button_capture - ) - ) - } - - NpadStyleIndex.JoyconRight -> { - add(HeaderSetting(R.string.buttons)) - add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) - add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) - add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) - add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) - add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.button_plus)) - add(ButtonInputSetting(playerIndex, NativeButton.Home, R.string.button_home)) - } - - NpadStyleIndex.GameCube -> { - add(HeaderSetting(R.string.buttons)) - add(ButtonInputSetting(playerIndex, NativeButton.A, R.string.button_a)) - add(ButtonInputSetting(playerIndex, NativeButton.B, R.string.button_b)) - add(ButtonInputSetting(playerIndex, NativeButton.X, R.string.button_x)) - add(ButtonInputSetting(playerIndex, NativeButton.Y, R.string.button_y)) - add(ButtonInputSetting(playerIndex, NativeButton.Plus, R.string.start_pause)) - } - - else -> { - // No-op - } - } - - when (styleIndex) { - NpadStyleIndex.Fullkey, - NpadStyleIndex.Handheld, - NpadStyleIndex.JoyconDual, - NpadStyleIndex.JoyconLeft -> { - add(HeaderSetting(R.string.dpad)) - add(ButtonInputSetting(playerIndex, NativeButton.DUp, R.string.up)) - add(ButtonInputSetting(playerIndex, NativeButton.DDown, R.string.down)) - add(ButtonInputSetting(playerIndex, NativeButton.DLeft, R.string.left)) - add(ButtonInputSetting(playerIndex, NativeButton.DRight, R.string.right)) - } - - else -> { - // No-op - } - } - - // Left stick - when (styleIndex) { - NpadStyleIndex.Fullkey, - NpadStyleIndex.Handheld, - NpadStyleIndex.JoyconDual, - NpadStyleIndex.JoyconLeft -> { - add(HeaderSetting(R.string.left_stick)) - addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) - add(ButtonInputSetting(playerIndex, NativeButton.LStick, R.string.pressed)) - addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) - } - - NpadStyleIndex.GameCube -> { - add(HeaderSetting(R.string.control_stick)) - addAll(getStickDirections(playerIndex, NativeAnalog.LStick)) - addAll(getExtraStickSettings(playerIndex, NativeAnalog.LStick)) - } - - else -> { - // No-op - } - } - - // Right stick - when (styleIndex) { - NpadStyleIndex.Fullkey, - NpadStyleIndex.Handheld, - NpadStyleIndex.JoyconDual, - NpadStyleIndex.JoyconRight -> { - add(HeaderSetting(R.string.right_stick)) - addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) - add(ButtonInputSetting(playerIndex, NativeButton.RStick, R.string.pressed)) - addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) - } - - NpadStyleIndex.GameCube -> { - add(HeaderSetting(R.string.c_stick)) - addAll(getStickDirections(playerIndex, NativeAnalog.RStick)) - addAll(getExtraStickSettings(playerIndex, NativeAnalog.RStick)) - } - - else -> { - // No-op - } - } - - // L/R, ZL/ZR, and SL/SR - when (styleIndex) { - NpadStyleIndex.Fullkey, - NpadStyleIndex.Handheld -> { - add(HeaderSetting(R.string.triggers)) - add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) - add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) - add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) - add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) - } - - NpadStyleIndex.JoyconDual -> { - add(HeaderSetting(R.string.triggers)) - add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) - add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) - add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) - add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SLLeft, - R.string.button_sl_left - ) - ) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SRLeft, - R.string.button_sr_left - ) - ) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SLRight, - R.string.button_sl_right - ) - ) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SRRight, - R.string.button_sr_right - ) - ) - } - - NpadStyleIndex.JoyconLeft -> { - add(HeaderSetting(R.string.triggers)) - add(ButtonInputSetting(playerIndex, NativeButton.L, R.string.button_l)) - add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_zl)) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SLLeft, - R.string.button_sl_left - ) - ) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SRLeft, - R.string.button_sr_left - ) - ) - } - - NpadStyleIndex.JoyconRight -> { - add(HeaderSetting(R.string.triggers)) - add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_r)) - add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_zr)) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SLRight, - R.string.button_sl_right - ) - ) - add( - ButtonInputSetting( - playerIndex, - NativeButton.SRRight, - R.string.button_sr_right - ) - ) - } - - NpadStyleIndex.GameCube -> { - add(HeaderSetting(R.string.triggers)) - add(ButtonInputSetting(playerIndex, NativeButton.R, R.string.button_z)) - add(ButtonInputSetting(playerIndex, NativeButton.ZL, R.string.button_l)) - add(ButtonInputSetting(playerIndex, NativeButton.ZR, R.string.button_r)) - } - - else -> { - // No-op - } - } - - add(HeaderSetting(R.string.vibration)) - val vibrationEnabledSetting = object : AbstractBooleanSetting { - override val key = "vibration" - - override fun getBoolean(needsGlobal: Boolean): Boolean = - NativeConfig.getInputSettings(true)[playerIndex].vibrationEnabled - - override fun setBoolean(value: Boolean) { - val settings = NativeConfig.getInputSettings(true) - settings[playerIndex].vibrationEnabled = value - NativeConfig.setInputSettings(settings, true) - } - - override val defaultValue = true - - override fun getValueAsString(needsGlobal: Boolean): String = - getBoolean(needsGlobal).toString() - - override fun reset() = setBoolean(defaultValue) - } - add(SwitchSetting(vibrationEnabledSetting, R.string.vibration)) - - val useSystemVibratorSetting = object : AbstractBooleanSetting { - override val key = "" - - override fun getBoolean(needsGlobal: Boolean): Boolean = - NativeConfig.getInputSettings(true)[playerIndex].useSystemVibrator - - override fun setBoolean(value: Boolean) { - val settings = NativeConfig.getInputSettings(true) - settings[playerIndex].useSystemVibrator = value - NativeConfig.setInputSettings(settings, true) - } - - override val defaultValue = playerIndex == 0 - - override fun getValueAsString(needsGlobal: Boolean): String = - getBoolean(needsGlobal).toString() - - override fun reset() = setBoolean(defaultValue) - - override val pairedSettingKey: String = "vibration" - } - addAbstract(SwitchSetting(useSystemVibratorSetting, R.string.use_system_vibrator)) - - val vibrationStrengthSetting = object : AbstractIntSetting { - override val key = "" - - override fun getInt(needsGlobal: Boolean): Int = - NativeConfig.getInputSettings(true)[playerIndex].vibrationStrength - - override fun setInt(value: Int) { - val settings = NativeConfig.getInputSettings(true) - settings[playerIndex].vibrationStrength = value - NativeConfig.setInputSettings(settings, true) - } - - override val defaultValue = 100 - - override fun getValueAsString(needsGlobal: Boolean): String = - getInt(needsGlobal).toString() - - override fun reset() = setInt(defaultValue) - - override val pairedSettingKey: String = "vibration" - } - addAbstract( - SliderSetting(vibrationStrengthSetting, R.string.vibration_strength, units = "%") - ) - } - } - - // Convenience function for creating AbstractIntSettings for modifier range/stick range/stick deadzones - private fun getStickIntSettingFromParam( - playerIndex: Int, - paramName: String, - stick: NativeAnalog, - defaultValue: Float - ): AbstractIntSetting = - object : AbstractIntSetting { - val params get() = NativeInput.getStickParam(playerIndex, stick) - - override val key = "" - - override fun getInt(needsGlobal: Boolean): Int = - (params.get(paramName, defaultValue) * 100).toInt() - - override fun setInt(value: Int) { - val tempParams = params - tempParams.set(paramName, value.toFloat() / 100) - NativeInput.setStickParam(playerIndex, stick, tempParams) - } - - override val defaultValue = (defaultValue * 100).toInt() - - override fun getValueAsString(needsGlobal: Boolean): String = - getInt(needsGlobal).toString() - - override fun reset() = setInt(this.defaultValue) - } - - private fun getExtraStickSettings( - playerIndex: Int, - nativeAnalog: NativeAnalog - ): List { - val stickIsController = - NativeInput.isController(NativeInput.getStickParam(playerIndex, nativeAnalog)) - val modifierRangeSetting = - getStickIntSettingFromParam(playerIndex, "modifier_scale", nativeAnalog, 0.5f) - val stickRangeSetting = - getStickIntSettingFromParam(playerIndex, "range", nativeAnalog, 0.95f) - val stickDeadzoneSetting = - getStickIntSettingFromParam(playerIndex, "deadzone", nativeAnalog, 0.15f) - - val out = mutableListOf().apply { - if (stickIsController) { - add(SliderSetting(stickRangeSetting, titleId = R.string.range, min = 25, max = 150)) - add(SliderSetting(stickDeadzoneSetting, R.string.deadzone)) - } else { - add(ModifierInputSetting(playerIndex, NativeAnalog.LStick, R.string.modifier)) - add(SliderSetting(modifierRangeSetting, R.string.modifier_range)) - } - } - return out - } - - private fun getStickDirections(player: Int, stick: NativeAnalog): List = - listOf( - AnalogInputSetting( - player, - stick, - AnalogDirection.Up, - R.string.up - ), - AnalogInputSetting( - player, - stick, - AnalogDirection.Down, - R.string.down - ), - AnalogInputSetting( - player, - stick, - AnalogDirection.Left, - R.string.left - ), - AnalogInputSetting( - player, - stick, - AnalogDirection.Right, - R.string.right - ) - ) - - private fun addThemeSettings(sl: ArrayList) { - sl.apply { - val theme: AbstractIntSetting = object : AbstractIntSetting { - override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME.getInt() - override fun setInt(value: Int) { - IntSetting.THEME.setInt(value) - settingsViewModel.setShouldRecreate(true) - } - - override val key: String = IntSetting.THEME.key - override val isRuntimeModifiable: Boolean = IntSetting.THEME.isRuntimeModifiable - override fun getValueAsString(needsGlobal: Boolean): String = - IntSetting.THEME.getValueAsString() - - override val defaultValue: Int = IntSetting.THEME.defaultValue - override fun reset() = IntSetting.THEME.setInt(defaultValue) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - add( - SingleChoiceSetting( - theme, - titleId = R.string.change_app_theme, - choicesId = R.array.themeEntriesA12, - valuesId = R.array.themeValuesA12 - ) - ) - } else { - add( - SingleChoiceSetting( - theme, - titleId = R.string.change_app_theme, - choicesId = R.array.themeEntries, - valuesId = R.array.themeValues - ) - ) - } - - val themeMode: AbstractIntSetting = object : AbstractIntSetting { - override fun getInt(needsGlobal: Boolean): Int = IntSetting.THEME_MODE.getInt() - override fun setInt(value: Int) { - IntSetting.THEME_MODE.setInt(value) - settingsViewModel.setShouldRecreate(true) - } - - override val key: String = IntSetting.THEME_MODE.key - override val isRuntimeModifiable: Boolean = - IntSetting.THEME_MODE.isRuntimeModifiable - - override fun getValueAsString(needsGlobal: Boolean): String = - IntSetting.THEME_MODE.getValueAsString() - - override val defaultValue: Int = IntSetting.THEME_MODE.defaultValue - override fun reset() { - IntSetting.THEME_MODE.setInt(defaultValue) - settingsViewModel.setShouldRecreate(true) - } - } - - add( - SingleChoiceSetting( - themeMode, - titleId = R.string.change_theme_mode, - choicesId = R.array.themeModeEntries, - valuesId = R.array.themeModeValues - ) - ) - - val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { - override fun getBoolean(needsGlobal: Boolean): Boolean = - BooleanSetting.BLACK_BACKGROUNDS.getBoolean() - - override fun setBoolean(value: Boolean) { - BooleanSetting.BLACK_BACKGROUNDS.setBoolean(value) - settingsViewModel.setShouldRecreate(true) - } - - override val key: String = BooleanSetting.BLACK_BACKGROUNDS.key - override val isRuntimeModifiable: Boolean = - BooleanSetting.BLACK_BACKGROUNDS.isRuntimeModifiable - - override fun getValueAsString(needsGlobal: Boolean): String = - BooleanSetting.BLACK_BACKGROUNDS.getValueAsString() - - override val defaultValue: Boolean = BooleanSetting.BLACK_BACKGROUNDS.defaultValue - override fun reset() { - BooleanSetting.BLACK_BACKGROUNDS - .setBoolean(BooleanSetting.BLACK_BACKGROUNDS.defaultValue) - settingsViewModel.setShouldRecreate(true) - } - } - - add( - SwitchSetting( - blackBackgrounds, - titleId = R.string.use_black_backgrounds, - descriptionId = R.string.use_black_backgrounds_description - ) - ) - } - } - - private fun addDebugSettings(sl: ArrayList) { - sl.apply { - add(HeaderSetting(R.string.gpu)) - add(IntSetting.RENDERER_BACKEND.key) - add(BooleanSetting.RENDERER_DEBUG.key) - - add(HeaderSetting(R.string.cpu)) - add(IntSetting.CPU_BACKEND.key) - add(IntSetting.CPU_ACCURACY.key) - add(BooleanSetting.CPU_DEBUG_MODE.key) - add(SettingsItem.FASTMEM_COMBINED) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt deleted file mode 100644 index ed60cf34f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsSearchFragment.kt +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -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.recyclerview.widget.LinearLayoutManager -import com.google.android.material.divider.MaterialDividerItemDecoration -import com.google.android.material.transition.MaterialSharedAxis -import info.debatty.java.stringsimilarity.Cosine -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentSettingsSearchBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect - -class SettingsSearchFragment : Fragment() { - private var _binding: FragmentSettingsSearchBinding? = null - private val binding get() = _binding!! - - private var settingsAdapter: SettingsAdapter? = null - - private val settingsViewModel: SettingsViewModel 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) - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSettingsSearchBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (savedInstanceState != null) { - binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) - } - - settingsAdapter = SettingsAdapter(this, requireContext()) - - val dividerDecoration = MaterialDividerItemDecoration( - requireContext(), - LinearLayoutManager.VERTICAL - ) - dividerDecoration.isLastItemDecorated = false - binding.settingsList.apply { - adapter = settingsAdapter - layoutManager = LinearLayoutManager(requireContext()) - addItemDecoration(dividerDecoration) - } - - focusSearch() - - binding.backButton.setOnClickListener { settingsViewModel.setShouldNavigateBack(true) } - binding.searchBackground.setOnClickListener { focusSearch() } - binding.clearButton.setOnClickListener { binding.searchText.setText("") } - binding.searchText.doOnTextChanged { _, _, _, _ -> - search() - binding.settingsList.smoothScrollToPosition(0) - } - settingsViewModel.shouldReloadSettingsList.collect(viewLifecycleOwner) { - if (it) { - settingsViewModel.setShouldReloadSettingsList(false) - search() - } - } - - search() - - setInsets() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) - } - - private fun search() { - val searchTerm = binding.searchText.text.toString().lowercase() - binding.clearButton.setVisible(visible = searchTerm.isNotEmpty(), gone = false) - if (searchTerm.isEmpty()) { - binding.noResultsView.setVisible(visible = false, gone = false) - settingsAdapter?.submitList(emptyList()) - return - } - - val baseList = SettingsItem.settingsItems - val similarityAlgorithm = if (searchTerm.length > 2) Cosine() else Cosine(1) - val sortedList: List = baseList.mapNotNull { item -> - val title = item.value.title.lowercase() - val similarity = similarityAlgorithm.similarity(searchTerm, title) - if (similarity > 0.08) { - Pair(similarity, item) - } else { - null - } - }.sortedByDescending { it.first }.mapNotNull { - val item = it.second.value - val pairedSettingKey = item.setting.pairedSettingKey - val optionalSetting: SettingsItem? = if (pairedSettingKey.isNotEmpty()) { - val pairedSettingValue = NativeConfig.getBoolean(pairedSettingKey, false) - if (pairedSettingValue) it.second.value else null - } else { - it.second.value - } - optionalSetting - } - settingsAdapter?.submitList(sortedList) - binding.noResultsView.setVisible(visible = sortedList.isEmpty(), gone = false) - } - - private fun focusSearch() { - 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, windowInsets: WindowInsetsCompat -> - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) - val sideMargin = resources.getDimensionPixelSize(R.dimen.spacing_medlarge) - val topMargin = resources.getDimensionPixelSize(R.dimen.spacing_chip) - - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.settingsList.updatePadding(bottom = barInsets.bottom + extraListSpacing) - binding.frameSearch.updatePadding( - left = leftInsets + sideMargin, - top = barInsets.top + topMargin, - right = rightInsets + sideMargin - ) - binding.noResultsView.updatePadding( - left = leftInsets, - right = rightInsets, - bottom = barInsets.bottom - ) - - binding.settingsList.updateMargins( - left = leftInsets + sideMargin, - right = rightInsets + sideMargin - ) - binding.divider.updateMargins( - left = leftInsets + sideMargin, - right = rightInsets + sideMargin - ) - - windowInsets - } - - companion object { - const val SEARCH_TEXT = "SearchText" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt deleted file mode 100644 index fbdca04e9..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsViewModel.kt +++ /dev/null @@ -1,112 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.utils.InputHandler -import org.yuzu.yuzu_emu.utils.ParamPackage - -class SettingsViewModel : ViewModel() { - var game: Game? = null - - var clickedItem: SettingsItem? = null - - var currentDevice = 0 - - val shouldRecreate: StateFlow get() = _shouldRecreate - private val _shouldRecreate = MutableStateFlow(false) - - val shouldNavigateBack: StateFlow get() = _shouldNavigateBack - private val _shouldNavigateBack = MutableStateFlow(false) - - val shouldShowResetSettingsDialog: StateFlow get() = _shouldShowResetSettingsDialog - private val _shouldShowResetSettingsDialog = MutableStateFlow(false) - - val shouldReloadSettingsList: StateFlow get() = _shouldReloadSettingsList - private val _shouldReloadSettingsList = MutableStateFlow(false) - - val sliderProgress: StateFlow get() = _sliderProgress - private val _sliderProgress = MutableStateFlow(-1) - - val sliderTextValue: StateFlow get() = _sliderTextValue - private val _sliderTextValue = MutableStateFlow("") - - val adapterItemChanged: StateFlow get() = _adapterItemChanged - private val _adapterItemChanged = MutableStateFlow(-1) - - private val _datasetChanged = MutableStateFlow(false) - val datasetChanged = _datasetChanged.asStateFlow() - - private val _reloadListAndNotifyDataset = MutableStateFlow(false) - val reloadListAndNotifyDataset = _reloadListAndNotifyDataset.asStateFlow() - - private val _shouldShowDeleteProfileDialog = MutableStateFlow("") - val shouldShowDeleteProfileDialog = _shouldShowDeleteProfileDialog.asStateFlow() - - private val _shouldShowResetInputDialog = MutableStateFlow(false) - val shouldShowResetInputDialog = _shouldShowResetInputDialog.asStateFlow() - - fun setShouldRecreate(value: Boolean) { - _shouldRecreate.value = value - } - - fun setShouldNavigateBack(value: Boolean) { - _shouldNavigateBack.value = value - } - - fun setShouldShowResetSettingsDialog(value: Boolean) { - _shouldShowResetSettingsDialog.value = value - } - - fun setShouldReloadSettingsList(value: Boolean) { - _shouldReloadSettingsList.value = value - } - - fun setSliderTextValue(value: Float, units: String) { - _sliderProgress.value = value.toInt() - _sliderTextValue.value = String.format( - YuzuApplication.appContext.getString(R.string.value_with_units), - value.toInt().toString(), - units - ) - } - - fun setSliderProgress(value: Float) { - _sliderProgress.value = value.toInt() - } - - fun setAdapterItemChanged(value: Int) { - _adapterItemChanged.value = value - } - - fun setDatasetChanged(value: Boolean) { - _datasetChanged.value = value - } - - fun setReloadListAndNotifyDataset(value: Boolean) { - _reloadListAndNotifyDataset.value = value - } - - fun setShouldShowDeleteProfileDialog(profile: String) { - _shouldShowDeleteProfileDialog.value = profile - } - - fun setShouldShowResetInputDialog(value: Boolean) { - _shouldShowResetInputDialog.value = value - } - - fun getCurrentDeviceParams(defaultParams: ParamPackage): ParamPackage = - try { - InputHandler.registeredControllers[currentDevice] - } catch (e: IndexOutOfBoundsException) { - defaultParams - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt deleted file mode 100644 index 0309fad59..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: DateTimeSetting - - override fun bind(item: SettingsItem) { - setting = item as DateTimeSetting - binding.textSettingName.text = item.title - binding.textSettingDescription.setVisible(item.description.isNotEmpty()) - binding.textSettingDescription.text = item.description - binding.textSettingValue.setVisible(true) - val epochTime = setting.getValue() - val instant = Instant.ofEpochMilli(epochTime * 1000) - val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")) - val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) - binding.textSettingValue.text = dateFormatter.format(zonedTime) - - binding.buttonClear.setVisible(setting.clearable) - binding.buttonClear.setOnClickListener { - adapter.onClearClick(setting, bindingAdapterPosition) - } - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (setting.isEditable) { - adapter.onDateTimeClick(setting, bindingAdapterPosition) - } - } - - override fun onLongClick(clicked: View): Boolean { - if (setting.isEditable) { - return adapter.onLongClick(setting, bindingAdapterPosition) - } - return false - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt deleted file mode 100644 index 0815c36e2..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter - -class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - - init { - itemView.setOnClickListener(null) - } - - override fun bind(item: SettingsItem) { - binding.textHeaderName.text = item.title - } - - override fun onClick(clicked: View) { - // no-op - } - - override fun onLongClick(clicked: View): Boolean { - // no-op - return true - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt deleted file mode 100644 index 86af3a226..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputProfileViewHolder.kt +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.InputProfileSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class InputProfileViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: InputProfileSetting - - override fun bind(item: SettingsItem) { - setting = item as InputProfileSetting - binding.textSettingName.text = setting.title - binding.textSettingValue.text = - setting.getCurrentProfile().ifEmpty { binding.root.context.getString(R.string.not_set) } - - binding.textSettingDescription.setVisible(false) - binding.buttonClear.setVisible(false) - binding.icon.setVisible(false) - binding.buttonClear.setVisible(false) - } - - override fun onClick(clicked: View) = - adapter.onInputProfileClick(setting, bindingAdapterPosition) - - override fun onLongClick(clicked: View): Boolean = false -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt deleted file mode 100644 index 9d9047804..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/InputViewHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.databinding.ListItemSettingInputBinding -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.settings.model.view.AnalogInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.ButtonInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.InputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.ModifierInputSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class InputViewHolder(val binding: ListItemSettingInputBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: InputSetting - - override fun bind(item: SettingsItem) { - setting = item as InputSetting - binding.textSettingName.text = setting.title - binding.textSettingValue.text = setting.getSelectedValue() - - when (item) { - is AnalogInputSetting -> { - val param = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) - binding.buttonOptions.setVisible( - param.get("engine", "") == "analog_from_button" || - param.has("axis_x") || param.has("axis_y") - ) - } - - is ButtonInputSetting -> { - val param = NativeInput.getButtonParam(item.playerIndex, item.nativeButton) - binding.buttonOptions.setVisible( - param.has("code") || param.has("button") || param.has("hat") || - param.has("axis") - ) - } - - is ModifierInputSetting -> { - val params = NativeInput.getStickParam(item.playerIndex, item.nativeAnalog) - binding.buttonOptions.setVisible(params.has("modifier")) - } - } - - binding.buttonOptions.setOnClickListener(null) - binding.buttonOptions.setOnClickListener { - adapter.onInputOptionsClick(binding.buttonOptions, setting, bindingAdapterPosition) - } - } - - override fun onClick(clicked: View) = - adapter.onInputClick(setting, bindingAdapterPosition) - - override fun onLongClick(clicked: View): Boolean = - adapter.onLongClick(setting, bindingAdapterPosition) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt deleted file mode 100644 index fc2ffb515..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import androidx.core.content.res.ResourcesCompat -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: RunnableSetting - - override fun bind(item: SettingsItem) { - setting = item as RunnableSetting - binding.icon.setVisible(setting.iconId != 0) - if (setting.iconId != 0) { - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.resources, - setting.iconId, - binding.icon.context.theme - ) - ) - } - - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) - binding.textSettingDescription.text = item.description - binding.textSettingValue.setVisible(false) - binding.buttonClear.setVisible(false) - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (setting.isRunnable) { - setting.runnable.invoke() - } - } - - override fun onLongClick(clicked: View): Boolean { - // no-op - return true - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt deleted file mode 100644 index d26887df8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter - -abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) : - RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener { - - init { - itemView.setOnClickListener(this) - itemView.setOnLongClickListener(this) - } - - /** - * Called by the adapter to set this ViewHolder's child views to display the list item - * it must now represent. - * - * @param item The list item that should be represented by this ViewHolder. - */ - abstract fun bind(item: SettingsItem) - - /** - * Called when this ViewHolder's view is clicked on. Implementations should usually pass - * this event up to the adapter. - * - * @param clicked The view that was clicked on. - */ - abstract override fun onClick(clicked: View) - - abstract override fun onLongClick(clicked: View): Boolean - - fun setStyle(isEditable: Boolean, binding: ListItemSettingBinding) { - val opacity = if (isEditable) 1.0f else 0.5f - binding.textSettingName.alpha = opacity - binding.textSettingDescription.alpha = opacity - binding.textSettingValue.alpha = opacity - binding.buttonClear.isEnabled = isEditable - } - - fun setStyle(isEditable: Boolean, binding: ListItemSettingSwitchBinding) { - binding.switchWidget.isEnabled = isEditable - val opacity = if (isEditable) 1.0f else 0.5f - binding.textSettingName.alpha = opacity - binding.textSettingDescription.alpha = opacity - binding.buttonClear.isEnabled = isEditable - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt deleted file mode 100644 index 489f55455..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.IntSingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: SettingsItem - - override fun bind(item: SettingsItem) { - setting = item - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(item.description.isNotEmpty()) - binding.textSettingDescription.text = item.description - - binding.textSettingValue.setVisible(true) - when (item) { - is SingleChoiceSetting -> { - val resMgr = binding.textSettingValue.context.resources - val values = resMgr.getIntArray(item.valuesId) - for (i in values.indices) { - if (values[i] == item.getSelectedValue()) { - binding.textSettingValue.text = resMgr.getStringArray(item.choicesId)[i] - break - } - } - } - - is StringSingleChoiceSetting -> { - binding.textSettingValue.text = item.getSelectedValue() - } - - is IntSingleChoiceSetting -> { - binding.textSettingValue.text = item.getChoiceAt(item.getSelectedValue()) - } - } - if (binding.textSettingValue.text.isEmpty()) { - binding.textSettingValue.setVisible(false) - } - - binding.buttonClear.setVisible(setting.clearable) - binding.buttonClear.setOnClickListener { - adapter.onClearClick(setting, bindingAdapterPosition) - } - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (!setting.isEditable) { - return - } - - when (setting) { - is SingleChoiceSetting -> adapter.onSingleChoiceClick( - setting as SingleChoiceSetting, - bindingAdapterPosition - ) - - is StringSingleChoiceSetting -> { - adapter.onStringSingleChoiceClick( - setting as StringSingleChoiceSetting, - bindingAdapterPosition - ) - } - - is IntSingleChoiceSetting -> { - adapter.onIntSingleChoiceClick( - setting as IntSingleChoiceSetting, - bindingAdapterPosition - ) - } - } - } - - override fun onLongClick(clicked: View): Boolean { - if (setting.isEditable) { - return adapter.onLongClick(setting, bindingAdapterPosition) - } - return false - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt deleted file mode 100644 index 90a7138cb..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: SliderSetting - - override fun bind(item: SettingsItem) { - setting = item as SliderSetting - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(item.description.isNotEmpty()) - binding.textSettingDescription.text = setting.description - binding.textSettingValue.setVisible(true) - binding.textSettingValue.text = String.format( - binding.textSettingValue.context.getString(R.string.value_with_units), - setting.getSelectedValue(), - setting.units - ) - - binding.buttonClear.setVisible(setting.clearable) - binding.buttonClear.setOnClickListener { - adapter.onClearClick(setting, bindingAdapterPosition) - } - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (setting.isEditable) { - adapter.onSliderClick(setting, bindingAdapterPosition) - } - } - - override fun onLongClick(clicked: View): Boolean { - if (setting.isEditable) { - return adapter.onLongClick(setting, bindingAdapterPosition) - } - return false - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt deleted file mode 100644 index a4fd36f62..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/StringInputViewHolder.kt +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.StringInputSetting -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class StringInputViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: StringInputSetting - - override fun bind(item: SettingsItem) { - setting = item as StringInputSetting - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) - binding.textSettingDescription.text = setting.description - binding.textSettingValue.setVisible(true) - binding.textSettingValue.text = setting.getSelectedValue() - - binding.buttonClear.setVisible(setting.clearable) - binding.buttonClear.setOnClickListener { - adapter.onClearClick(setting, bindingAdapterPosition) - } - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (setting.isEditable) { - adapter.onStringInputClick(setting, bindingAdapterPosition) - } - } - - override fun onLongClick(clicked: View): Boolean { - if (setting.isEditable) { - return adapter.onLongClick(setting, bindingAdapterPosition) - } - return false - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt deleted file mode 100644 index f7a9c08c3..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import androidx.core.content.res.ResourcesCompat -import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - private lateinit var setting: SubmenuSetting - - override fun bind(item: SettingsItem) { - setting = item as SubmenuSetting - binding.icon.setVisible(setting.iconId != 0) - if (setting.iconId != 0) { - binding.icon.setImageDrawable( - ResourcesCompat.getDrawable( - binding.icon.resources, - setting.iconId, - binding.icon.context.theme - ) - ) - } - - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) - binding.textSettingDescription.text = setting.description - binding.textSettingValue.setVisible(false) - binding.buttonClear.setVisible(false) - } - - override fun onClick(clicked: View) { - adapter.onSubmenuClick(setting) - } - - override fun onLongClick(clicked: View): Boolean { - // no-op - return true - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt deleted file mode 100644 index e5763264a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.ui.viewholder - -import android.view.View -import android.widget.CompoundButton -import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding -import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem -import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting -import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible - -class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) : - SettingViewHolder(binding.root, adapter) { - - private lateinit var setting: SwitchSetting - - override fun bind(item: SettingsItem) { - setting = item as SwitchSetting - binding.textSettingName.text = setting.title - binding.textSettingDescription.setVisible(setting.description.isNotEmpty()) - binding.textSettingDescription.text = setting.description - - binding.switchWidget.setOnCheckedChangeListener(null) - binding.switchWidget.isChecked = setting.getIsChecked(setting.needsRuntimeGlobal) - binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean -> - adapter.onBooleanClick(setting, binding.switchWidget.isChecked, bindingAdapterPosition) - } - - binding.buttonClear.setVisible(setting.clearable) - binding.buttonClear.setOnClickListener { - adapter.onClearClick(setting, bindingAdapterPosition) - } - - setStyle(setting.isEditable, binding) - } - - override fun onClick(clicked: View) { - if (setting.isEditable) { - binding.switchWidget.toggle() - } - } - - override fun onLongClick(clicked: View): Boolean { - if (setting.isEditable) { - return adapter.onLongClick(setting, bindingAdapterPosition) - } - return false - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt deleted file mode 100644 index 5d523be67..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.features.settings.utils - -import android.net.Uri -import org.yuzu.yuzu_emu.model.Game -import java.io.* -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.NativeConfig - -/** - * Contains static methods for interacting with .ini files in which settings are stored. - */ -object SettingsFile { - const val FILE_NAME_CONFIG = "config.ini" - - fun getSettingsFile(fileName: String): File = - File(DirectoryInitialization.userDirectory + "/config/" + fileName) - - fun getCustomSettingsFile(game: Game): File = - File(DirectoryInitialization.userDirectory + "/config/custom/" + game.settingsName + ".ini") - - fun loadCustomConfig(game: Game) { - val fileName = FileUtil.getFilename(Uri.parse(game.path)) - NativeConfig.initializePerGameConfig(game.programId, fileName) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt deleted file mode 100644 index ff4f0e5df..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt +++ /dev/null @@ -1,124 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.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.yuzu.yuzu_emu.BuildConfig -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -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.imageLogo.setOnLongClickListener { - Toast.makeText( - requireContext(), - R.string.gaia_is_not_real, - Toast.LENGTH_SHORT - ).show() - true - } - - binding.buttonContributors.setOnClickListener { - openLink( - getString(R.string.contributors_link) - ) - } - binding.buttonLicenses.setOnClickListener { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment) - } - - binding.textVersionName.text = BuildConfig.VERSION_NAME - binding.buttonVersionName.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 - - binding.toolbarAbout.updateMargins(left = leftInsets, right = rightInsets) - binding.scrollAbout.updateMargins(left = leftInsets, right = rightInsets) - - binding.contentAbout.updatePadding(bottom = barInsets.bottom) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt deleted file mode 100644 index 9fab88248..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddGameFolderDialogFragment.kt +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.net.Uri -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogAddFolderBinding -import org.yuzu.yuzu_emu.model.GameDir -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel - -class AddGameFolderDialogFragment : DialogFragment() { - private val homeViewModel: HomeViewModel by activityViewModels() - private val gamesViewModel: GamesViewModel by activityViewModels() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogAddFolderBinding.inflate(layoutInflater) - val folderUriString = requireArguments().getString(FOLDER_URI_STRING) - if (folderUriString == null) { - dismiss() - } - binding.path.text = Uri.parse(folderUriString).path - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.add_game_folder) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - val newGameDir = GameDir(folderUriString!!, binding.deepScanSwitch.isChecked) - homeViewModel.setGamesDirSelected(true) - gamesViewModel.addFolder(newGameDir) - } - .setNegativeButton(android.R.string.cancel, null) - .setView(binding.root) - .show() - } - - companion object { - const val TAG = "AddGameFolderDialogFragment" - - private const val FOLDER_URI_STRING = "FolderUriString" - - fun newInstance(folderUriString: String): AddGameFolderDialogFragment { - val args = Bundle() - args.putString(FOLDER_URI_STRING, folderUriString) - val fragment = AddGameFolderDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt deleted file mode 100644 index 110aa2960..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AddonsFragment.kt +++ /dev/null @@ -1,205 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.launch -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.AddonAdapter -import org.yuzu.yuzu_emu.databinding.FragmentAddonsBinding -import org.yuzu.yuzu_emu.model.AddonViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.AddonUtil -import org.yuzu.yuzu_emu.utils.FileUtil.copyFilesTo -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect -import java.io.File - -class AddonsFragment : Fragment() { - private var _binding: FragmentAddonsBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - private val addonViewModel: AddonViewModel by activityViewModels() - - private val args by navArgs() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - addonViewModel.onOpenAddons(args.game) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAddonsBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = false) - homeViewModel.setStatusBarShadeVisibility(false) - - binding.toolbarAddons.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() - } - - binding.toolbarAddons.title = getString(R.string.addons_game, args.game.title) - - binding.listAddons.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = AddonAdapter(addonViewModel) - } - - addonViewModel.addonList.collect(viewLifecycleOwner) { - (binding.listAddons.adapter as AddonAdapter).submitList(it) - } - addonViewModel.showModInstallPicker.collect( - viewLifecycleOwner, - resetState = { addonViewModel.showModInstallPicker(false) } - ) { if (it) installAddon.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) } - addonViewModel.showModNoticeDialog.collect( - viewLifecycleOwner, - resetState = { addonViewModel.showModNoticeDialog(false) } - ) { - if (it) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.addon_notice, - descriptionId = R.string.addon_notice_description, - dismissible = false, - positiveAction = { addonViewModel.showModInstallPicker(true) }, - negativeAction = {}, - negativeButtonTitleId = R.string.close - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - } - addonViewModel.addonToDelete.collect( - viewLifecycleOwner, - resetState = { addonViewModel.setAddonToDelete(null) } - ) { - if (it != null) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.confirm_uninstall, - descriptionId = R.string.confirm_uninstall_description, - positiveAction = { addonViewModel.onDeleteAddon(it) }, - negativeAction = {} - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - } - - binding.buttonInstall.setOnClickListener { - ContentTypeSelectionDialogFragment().show( - parentFragmentManager, - ContentTypeSelectionDialogFragment.TAG - ) - } - - setInsets() - } - - override fun onResume() { - super.onResume() - addonViewModel.refreshAddons() - } - - override fun onDestroy() { - super.onDestroy() - addonViewModel.onCloseAddons() - } - - val installAddon = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> - if (result == null) { - return@registerForActivityResult - } - - val externalAddonDirectory = DocumentFile.fromTreeUri(requireContext(), result) - if (externalAddonDirectory == null) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.invalid_directory, - descriptionId = R.string.invalid_directory_description - ).show(parentFragmentManager, MessageDialogFragment.TAG) - return@registerForActivityResult - } - - val isValid = externalAddonDirectory.listFiles() - .any { AddonUtil.validAddonDirectories.contains(it.name?.lowercase()) } - val errorMessage = MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.invalid_directory, - descriptionId = R.string.invalid_directory_description - ) - if (isValid) { - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.installing_game_content, - false - ) { progressCallback, _ -> - val parentDirectoryName = externalAddonDirectory.name - val internalAddonDirectory = - File(args.game.addonDir + parentDirectoryName) - try { - externalAddonDirectory.copyFilesTo(internalAddonDirectory, progressCallback) - } catch (_: Exception) { - return@newInstance errorMessage - } - addonViewModel.refreshAddons() - return@newInstance getString(R.string.addon_installed_successfully) - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } else { - errorMessage.show(parentFragmentManager, MessageDialogFragment.TAG) - } - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarAddons.updateMargins(left = leftInsets, right = rightInsets) - binding.listAddons.updateMargins(left = leftInsets, right = rightInsets) - binding.listAddons.updatePadding( - bottom = barInsets.bottom + - resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) - ) - - val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) - binding.buttonInstall.updateMargins( - left = leftInsets + fabSpacing, - right = rightInsets + fabSpacing, - bottom = barInsets.bottom + fabSpacing - ) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt deleted file mode 100644 index 73ca40484..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AppletLauncherFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.recyclerview.widget.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.AppletAdapter -import org.yuzu.yuzu_emu.databinding.FragmentAppletLauncherBinding -import org.yuzu.yuzu_emu.model.Applet -import org.yuzu.yuzu_emu.model.AppletInfo -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -class AppletLauncherFragment : Fragment() { - private var _binding: FragmentAppletLauncherBinding? = 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 = FragmentAppletLauncherBinding.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) - - binding.toolbarApplets.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() - } - - val applets = listOf( - Applet( - R.string.album_applet, - R.string.album_applet_description, - R.drawable.ic_album, - AppletInfo.PhotoViewer - ), - Applet( - R.string.cabinet_applet, - R.string.cabinet_applet_description, - R.drawable.ic_nfc, - AppletInfo.Cabinet - ), - Applet( - R.string.mii_edit_applet, - R.string.mii_edit_applet_description, - R.drawable.ic_mii, - AppletInfo.MiiEdit - ) - ) - - binding.listApplets.apply { - layoutManager = GridLayoutManager( - requireContext(), - resources.getInteger(R.integer.grid_columns) - ) - adapter = AppletAdapter(requireActivity(), applets) - } - - setInsets() - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarApplets.updateMargins(left = leftInsets, right = rightInsets) - binding.listApplets.updateMargins(left = leftInsets, right = rightInsets) - - binding.listApplets.updatePadding(bottom = barInsets.bottom) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt deleted file mode 100644 index 5933677fd..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CabinetLauncherDialogFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.CabinetLauncherDialogAdapter -import org.yuzu.yuzu_emu.databinding.DialogListBinding - -class CabinetLauncherDialogFragment : DialogFragment() { - private lateinit var binding: DialogListBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogListBinding.inflate(layoutInflater) - binding.dialogList.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = CabinetLauncherDialogAdapter(this@CabinetLauncherDialogFragment) - } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.cabinet_launcher) - .setView(binding.root) - .create() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return binding.root - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt deleted file mode 100644 index c1d8b9ea5..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ContentTypeSelectionDialogFragment.kt +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import androidx.preference.PreferenceManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.model.AddonViewModel -import org.yuzu.yuzu_emu.ui.main.MainActivity - -class ContentTypeSelectionDialogFragment : DialogFragment() { - private val addonViewModel: AddonViewModel by activityViewModels() - - private val preferences get() = - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - - private var selectedItem = 0 - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val launchOptions = - arrayOf(getString(R.string.updates_and_dlc), getString(R.string.mods_and_cheats)) - - if (savedInstanceState != null) { - selectedItem = savedInstanceState.getInt(SELECTED_ITEM) - } - - val mainActivity = requireActivity() as MainActivity - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.select_content_type) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - when (selectedItem) { - 0 -> mainActivity.installGameUpdate.launch(arrayOf("*/*")) - else -> { - if (!preferences.getBoolean(MOD_NOTICE_SEEN, false)) { - preferences.edit().putBoolean(MOD_NOTICE_SEEN, true).apply() - addonViewModel.showModNoticeDialog(true) - return@setPositiveButton - } - addonViewModel.showModInstallPicker(true) - } - } - } - .setSingleChoiceItems(launchOptions, 0) { _: DialogInterface, i: Int -> - selectedItem = i - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(SELECTED_ITEM, selectedItem) - } - - companion object { - const val TAG = "ContentTypeSelectionDialogFragment" - - private const val SELECTED_ITEM = "SelectedItem" - private const val MOD_NOTICE_SEEN = "ModNoticeSeen" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CoreErrorDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CoreErrorDialogFragment.kt deleted file mode 100644 index 299f8da71..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/CoreErrorDialogFragment.kt +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R - -class CoreErrorDialogFragment : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(requireArguments().getString(TITLE)) - .setMessage(requireArguments().getString(MESSAGE)) - .setPositiveButton(R.string.continue_button, null) - .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> - NativeLibrary.coreErrorAlertResult = false - synchronized(NativeLibrary.coreErrorAlertLock) { - NativeLibrary.coreErrorAlertLock.notify() - } - } - .create() - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - NativeLibrary.coreErrorAlertResult = true - synchronized(NativeLibrary.coreErrorAlertLock) { NativeLibrary.coreErrorAlertLock.notify() } - } - - companion object { - const val TITLE = "Title" - const val MESSAGE = "Message" - - fun newInstance(title: String, message: String): CoreErrorDialogFragment { - val frag = CoreErrorDialogFragment() - val args = Bundle() - args.putString(TITLE, title) - args.putString(MESSAGE, message) - frag.arguments = args - return frag - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt deleted file mode 100644 index 8b23a1021..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriverManagerFragment.kt +++ /dev/null @@ -1,199 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_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.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.DriverAdapter -import org.yuzu.yuzu_emu.databinding.FragmentDriverManagerBinding -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.GpuDriverHelper -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect -import java.io.File -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() - - private val args by navArgs() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - 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) - - driverViewModel.onOpenDriverManager(args.game) - if (NativeConfig.isPerGameConfigLoaded()) { - binding.toolbarDrivers.inflateMenu(R.menu.menu_driver_manager) - driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) - binding.toolbarDrivers.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_driver_use_global -> { - StringSetting.DRIVER_PATH.global = true - driverViewModel.updateDriverList() - (binding.listDrivers.adapter as DriverAdapter) - .replaceList(driverViewModel.driverList.value) - driverViewModel.showClearButton(false) - true - } - - else -> false - } - } - - driverViewModel.showClearButton.collect(viewLifecycleOwner) { - binding.toolbarDrivers.menu.findItem(R.id.menu_driver_use_global).isVisible = it - } - } - - if (!driverViewModel.isInteractionAllowed.value) { - 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.grid_columns) - ) - adapter = DriverAdapter(driverViewModel) - } - - setInsets() - } - - override fun onDestroy() { - super.onDestroy() - driverViewModel.onCloseDriverManager(args.game) - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarDrivers.updateMargins(left = leftInsets, right = rightInsets) - binding.listDrivers.updateMargins(left = leftInsets, right = rightInsets) - - val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) - binding.buttonInstall.updateMargins( - left = leftInsets + fabSpacing, - right = rightInsets + fabSpacing, - bottom = barInsets.bottom + fabSpacing - ) - - 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 - } - - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.installing_driver, - false - ) { _, _ -> - val driverPath = - "${GpuDriverHelper.driverStoragePath}${FileUtil.getFilename(result)}" - val driverFile = File(driverPath) - - // Ignore file exceptions when a user selects an invalid zip - try { - if (!GpuDriverHelper.copyDriverToInternalStorage(result)) { - throw IOException("Driver failed validation!") - } - } catch (_: IOException) { - if (driverFile.exists()) { - driverFile.delete() - } - return@newInstance getString(R.string.select_gpu_driver_error) - } - - val driverData = GpuDriverHelper.getMetadataFromZip(driverFile) - val driverInList = - driverViewModel.driverData.firstOrNull { it.second == driverData } - if (driverInList != null) { - return@newInstance getString(R.string.driver_already_installed) - } else { - driverViewModel.onDriverAdded(Pair(driverPath, driverData)) - withContext(Dispatchers.Main) { - if (_binding != null) { - val adapter = binding.listDrivers.adapter as DriverAdapter - adapter.addItem(driverData.toDriver()) - adapter.selectItem(adapter.currentList.indices.last) - driverViewModel.showClearButton(!StringSetting.DRIVER_PATH.global) - binding.listDrivers - .smoothScrollToPosition(adapter.currentList.indices.last) - } - } - } - return@newInstance Any() - }.show(childFragmentManager, ProgressDialogFragment.TAG) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt deleted file mode 100644 index bad56e434..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/DriversLoadingDialogFragment.kt +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.utils.collect - -class DriversLoadingDialogFragment : DialogFragment() { - private val driverViewModel: DriverViewModel by activityViewModels() - - private lateinit var binding: DialogProgressBarBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - binding = DialogProgressBarBinding.inflate(layoutInflater) - binding.progressBar.isIndeterminate = true - - isCancelable = false - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.loading) - .setView(binding.root) - .create() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View = binding.root - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { if (it) dismiss() } - } - - companion object { - const val TAG = "DriversLoadingDialogFragment" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt deleted file mode 100644 index 0534b68ce..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt +++ /dev/null @@ -1,87 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.fragment.findNavController -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -class EarlyAccessFragment : Fragment() { - private var _binding: FragmentEarlyAccessBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentEarlyAccessBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = false, animated = true) - homeViewModel.setStatusBarShadeVisibility(visible = false) - - binding.toolbarAbout.setNavigationOnClickListener { - parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack() - } - - binding.getEarlyAccessButton.setOnClickListener { - openLink( - getString(R.string.play_store_link) - ) - } - - setInsets() - } - - private fun openLink(link: String) { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) - startActivity(intent) - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.appbarEa.updateMargins(left = leftInsets, right = rightInsets) - - binding.scrollEa.updatePadding( - left = leftInsets, - right = rightInsets, - bottom = barInsets.bottom - ) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt deleted file mode 100644 index bcc880e17..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ /dev/null @@ -1,1048 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.content.Context -import android.content.DialogInterface -import android.content.pm.ActivityInfo -import android.content.res.Configuration -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.PowerManager -import android.os.SystemClock -import android.util.Rational -import android.view.* -import android.widget.FrameLayout -import android.widget.TextView -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.widget.PopupMenu -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding -import androidx.drawerlayout.widget.DrawerLayout -import androidx.drawerlayout.widget.DrawerLayout.DrawerListener -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoTracker -import androidx.window.layout.WindowLayoutInfo -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.slider.Slider -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.activities.EmulationActivity -import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding -import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationOrientation -import org.yuzu.yuzu_emu.features.settings.model.Settings.EmulationVerticalAlignment -import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.EmulationViewModel -import org.yuzu.yuzu_emu.overlay.model.OverlayControl -import org.yuzu.yuzu_emu.overlay.model.OverlayLayout -import org.yuzu.yuzu_emu.utils.* -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import java.lang.NullPointerException - -class EmulationFragment : Fragment(), SurfaceHolder.Callback { - private lateinit var emulationState: EmulationState - private var emulationActivity: EmulationActivity? = null - private var perfStatsUpdater: (() -> Unit)? = null - private var thermalStatsUpdater: (() -> Unit)? = null - - private var _binding: FragmentEmulationBinding? = null - private val binding get() = _binding!! - - private val args by navArgs() - - private lateinit var game: Game - - private val emulationViewModel: EmulationViewModel by activityViewModels() - private val driverViewModel: DriverViewModel by activityViewModels() - - private var isInFoldableLayout = false - - private lateinit var powerManager: PowerManager - - override fun onAttach(context: Context) { - super.onAttach(context) - if (context is EmulationActivity) { - emulationActivity = context - NativeLibrary.setEmulationActivity(context) - } else { - throw IllegalStateException("EmulationFragment must have EmulationActivity parent") - } - } - - /** - * Initialize anything that doesn't depend on the layout / views in here. - */ - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - updateOrientation() - - powerManager = requireContext().getSystemService(Context.POWER_SERVICE) as PowerManager - - val intentUri: Uri? = requireActivity().intent.data - var intentGame: Game? = null - if (intentUri != null) { - intentGame = if (Game.extensions.contains(FileUtil.getExtension(intentUri))) { - GameHelper.getGame(requireActivity().intent.data!!, false) - } else { - null - } - } - - try { - game = if (args.game != null) { - args.game!! - } else { - intentGame!! - } - } catch (e: NullPointerException) { - Toast.makeText( - requireContext(), - R.string.no_game_present, - Toast.LENGTH_SHORT - ).show() - requireActivity().finish() - return - } - - // Always load custom settings when launching a game from an intent - if (args.custom || intentGame != null) { - SettingsFile.loadCustomConfig(game) - NativeConfig.unloadPerGameConfig() - } else { - NativeConfig.reloadGlobalConfig() - } - - // Install the selected driver asynchronously as the game starts - driverViewModel.onLaunchGame() - - // So this fragment doesn't restart on configuration changes; i.e. rotation. - retainInstance = true - emulationState = EmulationState(game.path) { - return@EmulationState driverViewModel.isInteractionAllowed.value - } - } - - /** - * Initialize the UI and start emulation in here. - */ - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentEmulationBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - if (requireActivity().isFinishing) { - return - } - - binding.surfaceEmulation.holder.addCallback(this) - binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } - - binding.drawerLayout.addDrawerListener(object : DrawerListener { - override fun onDrawerSlide(drawerView: View, slideOffset: Float) { - binding.surfaceInputOverlay.dispatchTouchEvent( - MotionEvent.obtain( - SystemClock.uptimeMillis(), - SystemClock.uptimeMillis() + 100, - MotionEvent.ACTION_UP, - 0f, - 0f, - 0 - ) - ) - } - - override fun onDrawerOpened(drawerView: View) { - binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED) - binding.inGameMenu.requestFocus() - emulationViewModel.setDrawerOpen(true) - } - - override fun onDrawerClosed(drawerView: View) { - binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) - emulationViewModel.setDrawerOpen(false) - } - - override fun onDrawerStateChanged(newState: Int) { - // No op - } - }) - binding.drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - binding.inGameMenu.getHeaderView(0).findViewById(R.id.text_game_title).text = - game.title - - binding.inGameMenu.menu.findItem(R.id.menu_lock_drawer).apply { - val lockMode = IntSetting.LOCK_DRAWER.getInt() - val titleId = if (lockMode == DrawerLayout.LOCK_MODE_LOCKED_CLOSED) { - R.string.unlock_drawer - } else { - R.string.lock_drawer - } - val iconId = if (lockMode == DrawerLayout.LOCK_MODE_UNLOCKED) { - R.drawable.ic_unlock - } else { - R.drawable.ic_lock - } - - title = getString(titleId) - icon = ResourcesCompat.getDrawable( - resources, - iconId, - requireContext().theme - ) - } - - binding.inGameMenu.setNavigationItemSelectedListener { - when (it.itemId) { - R.id.menu_pause_emulation -> { - if (emulationState.isPaused) { - emulationState.run(false) - it.title = resources.getString(R.string.emulation_pause) - it.icon = ResourcesCompat.getDrawable( - resources, - R.drawable.ic_pause, - requireContext().theme - ) - } else { - emulationState.pause() - it.title = resources.getString(R.string.emulation_unpause) - it.icon = ResourcesCompat.getDrawable( - resources, - R.drawable.ic_play, - requireContext().theme - ) - } - binding.inGameMenu.requestFocus() - true - } - - R.id.menu_settings -> { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_ROOT - ) - binding.inGameMenu.requestFocus() - binding.root.findNavController().navigate(action) - true - } - - R.id.menu_settings_per_game -> { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - args.game, - Settings.MenuTag.SECTION_ROOT - ) - binding.inGameMenu.requestFocus() - binding.root.findNavController().navigate(action) - true - } - - R.id.menu_controls -> { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_INPUT - ) - binding.root.findNavController().navigate(action) - true - } - - R.id.menu_overlay_controls -> { - showOverlayOptions() - true - } - - R.id.menu_lock_drawer -> { - when (IntSetting.LOCK_DRAWER.getInt()) { - DrawerLayout.LOCK_MODE_UNLOCKED -> { - IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - it.title = resources.getString(R.string.unlock_drawer) - it.icon = ResourcesCompat.getDrawable( - resources, - R.drawable.ic_lock, - requireContext().theme - ) - } - - DrawerLayout.LOCK_MODE_LOCKED_CLOSED -> { - IntSetting.LOCK_DRAWER.setInt(DrawerLayout.LOCK_MODE_UNLOCKED) - it.title = resources.getString(R.string.lock_drawer) - it.icon = ResourcesCompat.getDrawable( - resources, - R.drawable.ic_unlock, - requireContext().theme - ) - } - } - binding.inGameMenu.requestFocus() - NativeConfig.saveGlobalConfig() - true - } - - R.id.menu_exit -> { - emulationState.stop() - NativeConfig.reloadGlobalConfig() - emulationViewModel.setIsEmulationStopping(true) - binding.drawerLayout.close() - binding.inGameMenu.requestFocus() - true - } - - else -> true - } - } - - setInsets() - - requireActivity().onBackPressedDispatcher.addCallback( - requireActivity(), - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (!NativeLibrary.isRunning()) { - return - } - emulationViewModel.setDrawerOpen(!binding.drawerLayout.isOpen) - } - } - ) - - GameIconUtils.loadGameIcon(game, binding.loadingImage) - binding.loadingTitle.text = game.title - binding.loadingTitle.isSelected = true - binding.loadingText.isSelected = true - - WindowInfoTracker.getOrCreate(requireContext()) - .windowLayoutInfo(requireActivity()).collect(viewLifecycleOwner) { - updateFoldableLayout(requireActivity() as EmulationActivity, it) - } - emulationViewModel.shaderProgress.collect(viewLifecycleOwner) { - if (it > 0 && it != emulationViewModel.totalShaders.value) { - binding.loadingProgressIndicator.isIndeterminate = false - - if (it < binding.loadingProgressIndicator.max) { - binding.loadingProgressIndicator.progress = it - } - } - - if (it == emulationViewModel.totalShaders.value) { - binding.loadingText.setText(R.string.loading) - binding.loadingProgressIndicator.isIndeterminate = true - } - } - emulationViewModel.totalShaders.collect(viewLifecycleOwner) { - binding.loadingProgressIndicator.max = it - } - emulationViewModel.shaderMessage.collect(viewLifecycleOwner) { - if (it.isNotEmpty()) { - binding.loadingText.text = it - } - } - - emulationViewModel.emulationStarted.collect(viewLifecycleOwner) { - if (it) { - binding.drawerLayout.setDrawerLockMode(IntSetting.LOCK_DRAWER.getInt()) - ViewUtils.showView(binding.surfaceInputOverlay) - ViewUtils.hideView(binding.loadingIndicator) - - emulationState.updateSurface() - - // Setup overlays - updateShowFpsOverlay() - updateThermalOverlay() - } - } - emulationViewModel.isEmulationStopping.collect(viewLifecycleOwner) { - if (it) { - binding.loadingText.setText(R.string.shutting_down) - ViewUtils.showView(binding.loadingIndicator) - ViewUtils.hideView(binding.inputContainer) - ViewUtils.hideView(binding.showFpsText) - } - } - emulationViewModel.drawerOpen.collect(viewLifecycleOwner) { - if (it) { - binding.drawerLayout.open() - binding.inGameMenu.requestFocus() - } else { - binding.drawerLayout.close() - } - } - emulationViewModel.programChanged.collect(viewLifecycleOwner) { - if (it != 0) { - emulationViewModel.setEmulationStarted(false) - binding.drawerLayout.close() - binding.drawerLayout - .setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED) - ViewUtils.hideView(binding.surfaceInputOverlay) - ViewUtils.showView(binding.loadingIndicator) - } - } - emulationViewModel.emulationStopped.collect(viewLifecycleOwner) { - if (it && emulationViewModel.programChanged.value != -1) { - if (perfStatsUpdater != null) { - perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) - } - emulationState.changeProgram(emulationViewModel.programChanged.value) - emulationViewModel.setProgramChanged(-1) - emulationViewModel.setEmulationStopped(false) - } - } - - driverViewModel.isInteractionAllowed.collect(viewLifecycleOwner) { - if (it) startEmulation() - } - } - - private fun startEmulation(programIndex: Int = 0) { - if (!NativeLibrary.isRunning() && !NativeLibrary.isPaused()) { - if (!DirectoryInitialization.areDirectoriesReady) { - DirectoryInitialization.start() - } - - updateScreenLayout() - - emulationState.run(emulationActivity!!.isActivityRecreated, programIndex) - } - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - if (_binding == null) { - return - } - - updateScreenLayout() - val showInputOverlay = BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() - if (emulationActivity?.isInPictureInPictureMode == true) { - if (binding.drawerLayout.isOpen) { - binding.drawerLayout.close() - } - if (showInputOverlay) { - binding.surfaceInputOverlay.setVisible(visible = false, gone = false) - } - } else { - binding.surfaceInputOverlay.setVisible( - showInputOverlay && emulationViewModel.emulationStarted.value - ) - if (!isInFoldableLayout) { - if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) { - binding.surfaceInputOverlay.layout = OverlayLayout.Portrait - } else { - binding.surfaceInputOverlay.layout = OverlayLayout.Landscape - } - } - } - } - - override fun onPause() { - if (emulationState.isRunning && emulationActivity?.isInPictureInPictureMode != true) { - emulationState.pause() - } - super.onPause() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onDetach() { - NativeLibrary.clearEmulationActivity() - super.onDetach() - } - - private fun resetInputOverlay() { - IntSetting.OVERLAY_SCALE.reset() - IntSetting.OVERLAY_OPACITY.reset() - binding.surfaceInputOverlay.post { - binding.surfaceInputOverlay.resetLayoutVisibilityAndPlacement() - } - } - - private fun updateShowFpsOverlay() { - val showOverlay = BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() - binding.showFpsText.setVisible(showOverlay) - if (showOverlay) { - val SYSTEM_FPS = 0 - val FPS = 1 - val FRAMETIME = 2 - val SPEED = 3 - perfStatsUpdater = { - if (emulationViewModel.emulationStarted.value && - !emulationViewModel.isEmulationStopping.value - ) { - val perfStats = NativeLibrary.getPerfStats() - val cpuBackend = NativeLibrary.getCpuBackend() - val gpuDriver = NativeLibrary.getGpuDriver() - if (_binding != null) { - binding.showFpsText.text = - String.format("FPS: %.1f\n%s/%s", perfStats[FPS], cpuBackend, gpuDriver) - } - perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 800) - } - } - perfStatsUpdateHandler.post(perfStatsUpdater!!) - } else { - if (perfStatsUpdater != null) { - perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) - } - } - } - - private fun updateThermalOverlay() { - val showOverlay = BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() - binding.showThermalsText.setVisible(showOverlay) - if (showOverlay) { - thermalStatsUpdater = { - if (emulationViewModel.emulationStarted.value && - !emulationViewModel.isEmulationStopping.value - ) { - val thermalStatus = when (powerManager.currentThermalStatus) { - PowerManager.THERMAL_STATUS_LIGHT -> "😥" - PowerManager.THERMAL_STATUS_MODERATE -> "🥵" - PowerManager.THERMAL_STATUS_SEVERE -> "🔥" - PowerManager.THERMAL_STATUS_CRITICAL, - PowerManager.THERMAL_STATUS_EMERGENCY, - PowerManager.THERMAL_STATUS_SHUTDOWN -> "☢️" - - else -> "🙂" - } - if (_binding != null) { - binding.showThermalsText.text = thermalStatus - } - thermalStatsUpdateHandler.postDelayed(thermalStatsUpdater!!, 1000) - } - } - thermalStatsUpdateHandler.post(thermalStatsUpdater!!) - } else { - if (thermalStatsUpdater != null) { - thermalStatsUpdateHandler.removeCallbacks(thermalStatsUpdater!!) - } - } - } - - @SuppressLint("SourceLockedOrientationActivity") - private fun updateOrientation() { - emulationActivity?.let { - val orientationSetting = - EmulationOrientation.from(IntSetting.RENDERER_SCREEN_LAYOUT.getInt()) - it.requestedOrientation = when (orientationSetting) { - EmulationOrientation.Unspecified -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - EmulationOrientation.SensorLandscape -> - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - - EmulationOrientation.Landscape -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE - EmulationOrientation.ReverseLandscape -> - ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE - - EmulationOrientation.SensorPortrait -> - ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT - - EmulationOrientation.Portrait -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - EmulationOrientation.ReversePortrait -> - ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT - } - } - } - - private fun updateScreenLayout() { - val verticalAlignment = - EmulationVerticalAlignment.from(IntSetting.VERTICAL_ALIGNMENT.getInt()) - val aspectRatio = when (IntSetting.RENDERER_ASPECT_RATIO.getInt()) { - 0 -> Rational(16, 9) - 1 -> Rational(4, 3) - 2 -> Rational(21, 9) - 3 -> Rational(16, 10) - else -> null // Best fit - } - when (verticalAlignment) { - EmulationVerticalAlignment.Top -> { - binding.surfaceEmulation.setAspectRatio(aspectRatio) - val params = FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - binding.surfaceEmulation.layoutParams = params - } - - EmulationVerticalAlignment.Center -> { - binding.surfaceEmulation.setAspectRatio(null) - binding.surfaceEmulation.updateLayoutParams { - width = ViewGroup.LayoutParams.MATCH_PARENT - height = ViewGroup.LayoutParams.MATCH_PARENT - } - } - - EmulationVerticalAlignment.Bottom -> { - binding.surfaceEmulation.setAspectRatio(aspectRatio) - val params = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL - binding.surfaceEmulation.layoutParams = params - } - } - emulationState.updateSurface() - emulationActivity?.buildPictureInPictureParams() - updateOrientation() - } - - private fun updateFoldableLayout( - emulationActivity: EmulationActivity, - newLayoutInfo: WindowLayoutInfo - ) { - val isFolding = - (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { - if (it.isSeparating) { - emulationActivity.requestedOrientation = - ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { - // Restrict emulation and overlays to the top of the screen - binding.emulationContainer.layoutParams.height = it.bounds.top - // Restrict input and menu drawer to the bottom of the screen - binding.inputContainer.layoutParams.height = it.bounds.bottom - binding.inGameMenu.layoutParams.height = it.bounds.bottom - - isInFoldableLayout = true - binding.surfaceInputOverlay.layout = OverlayLayout.Foldable - } - } - it.isSeparating - } ?: false - if (!isFolding) { - binding.emulationContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.inputContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - isInFoldableLayout = false - updateOrientation() - onConfigurationChanged(resources.configuration) - } - binding.emulationContainer.requestLayout() - binding.inputContainer.requestLayout() - binding.inGameMenu.requestLayout() - } - - override fun surfaceCreated(holder: SurfaceHolder) { - // We purposely don't do anything here. - // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) - emulationState.newSurface(holder.surface) - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - emulationState.clearSurface() - } - - private fun showOverlayOptions() { - val anchor = binding.inGameMenu.findViewById(R.id.menu_overlay_controls) - val popup = PopupMenu(requireContext(), anchor) - - popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) - - popup.menu.apply { - findItem(R.id.menu_toggle_fps).isChecked = - BooleanSetting.SHOW_PERFORMANCE_OVERLAY.getBoolean() - findItem(R.id.thermal_indicator).isChecked = - BooleanSetting.SHOW_THERMAL_OVERLAY.getBoolean() - findItem(R.id.menu_rel_stick_center).isChecked = - BooleanSetting.JOYSTICK_REL_CENTER.getBoolean() - findItem(R.id.menu_dpad_slide).isChecked = BooleanSetting.DPAD_SLIDE.getBoolean() - findItem(R.id.menu_show_overlay).isChecked = - BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean() - findItem(R.id.menu_haptics).isChecked = BooleanSetting.HAPTIC_FEEDBACK.getBoolean() - findItem(R.id.menu_touchscreen).isChecked = BooleanSetting.TOUCHSCREEN.getBoolean() - } - - popup.setOnDismissListener { NativeConfig.saveGlobalConfig() } - popup.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_toggle_fps -> { - it.isChecked = !it.isChecked - BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(it.isChecked) - updateShowFpsOverlay() - true - } - - R.id.thermal_indicator -> { - it.isChecked = !it.isChecked - BooleanSetting.SHOW_THERMAL_OVERLAY.setBoolean(it.isChecked) - updateThermalOverlay() - true - } - - R.id.menu_edit_overlay -> { - binding.drawerLayout.close() - binding.surfaceInputOverlay.requestFocus() - startConfiguringControls() - true - } - - R.id.menu_adjust_overlay -> { - adjustOverlay() - true - } - - R.id.menu_toggle_controls -> { - val overlayControlData = NativeConfig.getOverlayControlData() - val optionsArray = BooleanArray(overlayControlData.size) - overlayControlData.forEachIndexed { i, _ -> - optionsArray[i] = overlayControlData.firstOrNull { data -> - OverlayControl.entries[i].id == data.id - }?.enabled == true - } - - val dialog = MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.emulation_toggle_controls) - .setMultiChoiceItems( - R.array.gamepadButtons, - optionsArray - ) { _, indexSelected, isChecked -> - overlayControlData.firstOrNull { data -> - OverlayControl.entries[indexSelected].id == data.id - }?.enabled = isChecked - } - .setPositiveButton(android.R.string.ok) { _, _ -> - NativeConfig.setOverlayControlData(overlayControlData) - NativeConfig.saveGlobalConfig() - binding.surfaceInputOverlay.refreshControls() - } - .setNegativeButton(android.R.string.cancel, null) - .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } - .show() - - // Override normal behaviour so the dialog doesn't close - dialog.getButton(AlertDialog.BUTTON_NEUTRAL) - .setOnClickListener { - val isChecked = !optionsArray[0] - overlayControlData.forEachIndexed { i, _ -> - optionsArray[i] = isChecked - dialog.listView.setItemChecked(i, isChecked) - overlayControlData[i].enabled = isChecked - } - } - true - } - - R.id.menu_show_overlay -> { - it.isChecked = !it.isChecked - BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(it.isChecked) - binding.surfaceInputOverlay.refreshControls() - true - } - - R.id.menu_rel_stick_center -> { - it.isChecked = !it.isChecked - BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(it.isChecked) - true - } - - R.id.menu_dpad_slide -> { - it.isChecked = !it.isChecked - BooleanSetting.DPAD_SLIDE.setBoolean(it.isChecked) - true - } - - R.id.menu_haptics -> { - it.isChecked = !it.isChecked - BooleanSetting.HAPTIC_FEEDBACK.setBoolean(it.isChecked) - true - } - - R.id.menu_touchscreen -> { - it.isChecked = !it.isChecked - BooleanSetting.TOUCHSCREEN.setBoolean(it.isChecked) - true - } - - R.id.menu_reset_overlay -> { - binding.drawerLayout.close() - resetInputOverlay() - true - } - - else -> true - } - } - - popup.show() - } - - @SuppressLint("SourceLockedOrientationActivity") - private fun startConfiguringControls() { - // Lock the current orientation to prevent editing inconsistencies - if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { - emulationActivity?.let { - it.requestedOrientation = - if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { - ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT - } else { - ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE - } - } - } - binding.doneControlConfig.setVisible(true) - binding.surfaceInputOverlay.setIsInEditMode(true) - } - - private fun stopConfiguringControls() { - binding.doneControlConfig.setVisible(false) - binding.surfaceInputOverlay.setIsInEditMode(false) - // Unlock the orientation if it was locked for editing - if (IntSetting.RENDERER_SCREEN_LAYOUT.getInt() == EmulationOrientation.Unspecified.int) { - emulationActivity?.let { - it.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED - } - } - NativeConfig.saveGlobalConfig() - } - - @SuppressLint("SetTextI18n") - private fun adjustOverlay() { - val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) - adjustBinding.apply { - inputScaleSlider.apply { - valueTo = 150F - value = IntSetting.OVERLAY_SCALE.getInt().toFloat() - addOnChangeListener( - Slider.OnChangeListener { _, value, _ -> - inputScaleValue.text = "${value.toInt()}%" - setControlScale(value.toInt()) - } - ) - } - inputOpacitySlider.apply { - valueTo = 100F - value = IntSetting.OVERLAY_OPACITY.getInt().toFloat() - addOnChangeListener( - Slider.OnChangeListener { _, value, _ -> - inputOpacityValue.text = "${value.toInt()}%" - setControlOpacity(value.toInt()) - } - ) - } - inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" - inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" - } - - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.emulation_control_adjust) - .setView(adjustBinding.root) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> - NativeConfig.saveGlobalConfig() - } - .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> - setControlScale(50) - setControlOpacity(100) - } - .show() - } - - private fun setControlScale(scale: Int) { - IntSetting.OVERLAY_SCALE.setInt(scale) - binding.surfaceInputOverlay.refreshControls() - } - - private fun setControlOpacity(opacity: Int) { - IntSetting.OVERLAY_OPACITY.setInt(opacity) - binding.surfaceInputOverlay.refreshControls() - } - - private fun setInsets() { - ViewCompat.setOnApplyWindowInsetsListener( - binding.inGameMenu - ) { v: View, windowInsets: WindowInsetsCompat -> - val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - var left = 0 - var right = 0 - if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { - left = cutInsets.left - } else { - right = cutInsets.right - } - - v.updatePadding(left = left, top = cutInsets.top, right = right) - windowInsets - } - } - - private class EmulationState( - private val gamePath: String, - private val emulationCanStart: () -> Boolean - ) { - private var state: State - private var surface: Surface? = null - lateinit var emulationThread: Thread - - init { - // Starting state is stopped. - state = State.STOPPED - } - - @get:Synchronized - val isStopped: Boolean - get() = state == State.STOPPED - - // Getters for the current state - @get:Synchronized - val isPaused: Boolean - get() = state == State.PAUSED - - @get:Synchronized - val isRunning: Boolean - get() = state == State.RUNNING - - @Synchronized - fun stop() { - if (state != State.STOPPED) { - Log.debug("[EmulationFragment] Stopping emulation.") - NativeLibrary.stopEmulation() - state = State.STOPPED - } else { - Log.warning("[EmulationFragment] Stop called while already stopped.") - } - } - - // State changing methods - @Synchronized - fun pause() { - if (state != State.PAUSED) { - Log.debug("[EmulationFragment] Pausing emulation.") - - NativeLibrary.pauseEmulation() - - state = State.PAUSED - } else { - Log.warning("[EmulationFragment] Pause called while already paused.") - } - } - - @Synchronized - fun run(isActivityRecreated: Boolean, programIndex: Int = 0) { - if (isActivityRecreated) { - if (NativeLibrary.isRunning()) { - state = State.PAUSED - } - } else { - Log.debug("[EmulationFragment] activity resumed or fresh start") - } - - // If the surface is set, run now. Otherwise, wait for it to get set. - if (surface != null) { - runWithValidSurface(programIndex) - } - } - - @Synchronized - fun changeProgram(programIndex: Int) { - emulationThread.join() - emulationThread = Thread({ - Log.debug("[EmulationFragment] Starting emulation thread.") - NativeLibrary.run(gamePath, programIndex, false) - }, "NativeEmulation") - emulationThread.start() - } - - // Surface callbacks - @Synchronized - fun newSurface(surface: Surface?) { - this.surface = surface - if (this.surface != null) { - runWithValidSurface() - } - } - - @Synchronized - fun updateSurface() { - if (surface != null) { - NativeLibrary.surfaceChanged(surface) - } - } - - @Synchronized - fun clearSurface() { - if (surface == null) { - Log.warning("[EmulationFragment] clearSurface called, but surface already null.") - } else { - surface = null - Log.debug("[EmulationFragment] Surface destroyed.") - when (state) { - State.RUNNING -> { - state = State.PAUSED - } - - State.PAUSED -> Log.warning( - "[EmulationFragment] Surface cleared while emulation paused." - ) - - else -> Log.warning( - "[EmulationFragment] Surface cleared while emulation stopped." - ) - } - } - } - - private fun runWithValidSurface(programIndex: Int = 0) { - NativeLibrary.surfaceChanged(surface) - if (!emulationCanStart.invoke()) { - return - } - - when (state) { - State.STOPPED -> { - emulationThread = Thread({ - Log.debug("[EmulationFragment] Starting emulation thread.") - NativeLibrary.run(gamePath, programIndex, true) - }, "NativeEmulation") - emulationThread.start() - } - - State.PAUSED -> { - Log.debug("[EmulationFragment] Resuming emulation.") - NativeLibrary.unpauseEmulation() - } - - else -> Log.debug("[EmulationFragment] Bug, run called while already running.") - } - state = State.RUNNING - } - - private enum class State { - STOPPED, RUNNING, PAUSED - } - } - - companion object { - private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) - private val thermalStatsUpdateHandler = Handler(Looper.myLooper()!!) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt deleted file mode 100644 index 1ea1e036e..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFolderPropertiesDialogFragment.kt +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogFolderPropertiesBinding -import org.yuzu.yuzu_emu.model.GameDir -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable - -class GameFolderPropertiesDialogFragment : DialogFragment() { - private val gamesViewModel: GamesViewModel by activityViewModels() - - private var deepScan = false - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogFolderPropertiesBinding.inflate(layoutInflater) - val gameDir = requireArguments().parcelable(GAME_DIR)!! - - // Restore checkbox state - binding.deepScanSwitch.isChecked = - savedInstanceState?.getBoolean(DEEP_SCAN) ?: gameDir.deepScan - - // Ensure that we can get the checkbox state even if the view is destroyed - deepScan = binding.deepScanSwitch.isChecked - binding.deepScanSwitch.setOnClickListener { - deepScan = binding.deepScanSwitch.isChecked - } - - return MaterialAlertDialogBuilder(requireContext()) - .setView(binding.root) - .setTitle(R.string.game_folder_properties) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - val folderIndex = gamesViewModel.folders.value.indexOf(gameDir) - if (folderIndex != -1) { - gamesViewModel.folders.value[folderIndex].deepScan = - binding.deepScanSwitch.isChecked - gamesViewModel.updateGameDirs() - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - override fun onStop() { - super.onStop() - NativeConfig.saveGlobalConfig() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(DEEP_SCAN, deepScan) - } - - companion object { - const val TAG = "GameFolderPropertiesDialogFragment" - - private const val GAME_DIR = "GameDir" - - private const val DEEP_SCAN = "DeepScan" - - fun newInstance(gameDir: GameDir): GameFolderPropertiesDialogFragment { - val args = Bundle() - args.putParcelable(GAME_DIR, gameDir) - val fragment = GameFolderPropertiesDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt deleted file mode 100644 index 3a6f7a38c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameFoldersFragment.kt +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Intent -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.recyclerview.widget.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.launch -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.FolderAdapter -import org.yuzu.yuzu_emu.databinding.FragmentFoldersBinding -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect - -class GameFoldersFragment : Fragment() { - private var _binding: FragmentFoldersBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - private val gamesViewModel: GamesViewModel 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) - - gamesViewModel.onOpenGameFoldersFragment() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentFoldersBinding.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) - - binding.toolbarFolders.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() - } - - binding.listFolders.apply { - layoutManager = GridLayoutManager( - requireContext(), - resources.getInteger(R.integer.grid_columns) - ) - adapter = FolderAdapter(requireActivity(), gamesViewModel) - } - - gamesViewModel.folders.collect(viewLifecycleOwner) { - (binding.listFolders.adapter as FolderAdapter).submitList(it) - } - - val mainActivity = requireActivity() as MainActivity - binding.buttonAdd.setOnClickListener { - mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) - } - - setInsets() - } - - override fun onStop() { - super.onStop() - gamesViewModel.onCloseGameFoldersFragment() - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarFolders.updateMargins(left = leftInsets, right = rightInsets) - - val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) - binding.buttonAdd.updateMargins( - left = leftInsets + fabSpacing, - right = rightInsets + fabSpacing, - bottom = barInsets.bottom + fabSpacing - ) - - binding.listFolders.updateMargins(left = leftInsets, right = rightInsets) - - binding.listFolders.updatePadding( - bottom = barInsets.bottom + - resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) - ) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt deleted file mode 100644 index 97a8954bb..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GameInfoFragment.kt +++ /dev/null @@ -1,179 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.FragmentGameInfoBinding -import org.yuzu.yuzu_emu.model.GameVerificationResult -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.GameMetadata -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -class GameInfoFragment : Fragment() { - private var _binding: FragmentGameInfoBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - - private val args by navArgs() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - - // Check for an up-to-date version string - args.game.version = GameMetadata.getVersion(args.game.path, true) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentGameInfoBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = false) - homeViewModel.setStatusBarShadeVisibility(false) - - binding.apply { - toolbarInfo.title = args.game.title - toolbarInfo.setNavigationOnClickListener { - view.findNavController().popBackStack() - } - - val pathString = Uri.parse(args.game.path).path ?: "" - path.setHint(R.string.path) - pathField.setText(pathString) - pathField.setOnClickListener { copyToClipboard(getString(R.string.path), pathString) } - - programId.setHint(R.string.program_id) - programIdField.setText(args.game.programIdHex) - programIdField.setOnClickListener { - copyToClipboard(getString(R.string.program_id), args.game.programIdHex) - } - - if (args.game.developer.isNotEmpty()) { - developer.setHint(R.string.developer) - developerField.setText(args.game.developer) - developerField.setOnClickListener { - copyToClipboard(getString(R.string.developer), args.game.developer) - } - } else { - developer.setVisible(false) - } - - version.setHint(R.string.version) - versionField.setText(args.game.version) - versionField.setOnClickListener { - copyToClipboard(getString(R.string.version), args.game.version) - } - - buttonCopy.setOnClickListener { - val details = """ - ${args.game.title} - ${getString(R.string.path)} - $pathString - ${getString(R.string.program_id)} - ${args.game.programIdHex} - ${getString(R.string.developer)} - ${args.game.developer} - ${getString(R.string.version)} - ${args.game.version} - """.trimIndent() - copyToClipboard(args.game.title, details) - } - - buttonVerifyIntegrity.setOnClickListener { - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.verifying, - true - ) { progressCallback, _ -> - val result = GameVerificationResult.from( - NativeLibrary.verifyGameContents( - args.game.path, - progressCallback - ) - ) - return@newInstance when (result) { - GameVerificationResult.Success -> - MessageDialogFragment.newInstance( - titleId = R.string.verify_success, - descriptionId = R.string.operation_completed_successfully - ) - - GameVerificationResult.Failed -> - MessageDialogFragment.newInstance( - titleId = R.string.verify_failure, - descriptionId = R.string.verify_failure_description - ) - - GameVerificationResult.NotImplemented -> - MessageDialogFragment.newInstance( - titleId = R.string.verify_no_result, - descriptionId = R.string.verify_no_result_description - ) - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } - } - - setInsets() - } - - private fun copyToClipboard(label: String, body: String) { - val clipBoard = - requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(label, body) - clipBoard.setPrimaryClip(clip) - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - Toast.makeText( - requireContext(), - R.string.copied_to_clipboard, - Toast.LENGTH_SHORT - ).show() - } - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarInfo.updateMargins(left = leftInsets, right = rightInsets) - binding.scrollInfo.updateMargins(left = leftInsets, right = rightInsets) - - binding.contentInfo.updatePadding(bottom = barInsets.bottom) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt deleted file mode 100644 index c06842c59..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ /dev/null @@ -1,424 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.pm.ShortcutInfo -import android.content.pm.ShortcutManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -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.lifecycleScope -import androidx.navigation.findNavController -import androidx.navigation.fragment.navArgs -import androidx.recyclerview.widget.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter -import org.yuzu.yuzu_emu.databinding.FragmentGamePropertiesBinding -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.model.GameProperty -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.InstallableProperty -import org.yuzu.yuzu_emu.model.SubmenuProperty -import org.yuzu.yuzu_emu.model.TaskState -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.GameIconUtils -import org.yuzu.yuzu_emu.utils.GpuDriverHelper -import org.yuzu.yuzu_emu.utils.MemoryUtil -import org.yuzu.yuzu_emu.utils.ViewUtils.marquee -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect -import java.io.BufferedOutputStream -import java.io.File - -class GamePropertiesFragment : Fragment() { - private var _binding: FragmentGamePropertiesBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - private val gamesViewModel: GamesViewModel by activityViewModels() - private val driverViewModel: DriverViewModel by activityViewModels() - - private val args by navArgs() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.Y, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.Y, false) - reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentGamePropertiesBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = false, animated = true) - homeViewModel.setStatusBarShadeVisibility(true) - - binding.buttonBack.setOnClickListener { - view.findNavController().popBackStack() - } - - val shortcutManager = requireActivity().getSystemService(ShortcutManager::class.java) - binding.buttonShortcut.isEnabled = shortcutManager.isRequestPinShortcutSupported - binding.buttonShortcut.setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - withContext(Dispatchers.IO) { - val shortcut = ShortcutInfo.Builder(requireContext(), args.game.title) - .setShortLabel(args.game.title) - .setIcon( - GameIconUtils.getShortcutIcon(requireActivity(), args.game) - .toIcon(requireContext()) - ) - .setIntent(args.game.launchIntent) - .build() - shortcutManager.requestPinShortcut(shortcut, null) - } - } - } - - GameIconUtils.loadGameIcon(args.game, binding.imageGameScreen) - binding.title.text = args.game.title - binding.title.marquee() - - binding.buttonStart.setOnClickListener { - LaunchGameDialogFragment.newInstance(args.game) - .show(childFragmentManager, LaunchGameDialogFragment.TAG) - } - - reloadList() - - homeViewModel.openImportSaves.collect( - viewLifecycleOwner, - resetState = { homeViewModel.setOpenImportSaves(false) } - ) { if (it) importSaves.launch(arrayOf("application/zip")) } - homeViewModel.reloadPropertiesList.collect( - viewLifecycleOwner, - resetState = { homeViewModel.reloadPropertiesList(false) } - ) { if (it) reloadList() } - - setInsets() - } - - override fun onDestroy() { - super.onDestroy() - gamesViewModel.reloadGames(true) - } - - private fun reloadList() { - _binding ?: return - - driverViewModel.updateDriverNameForGame(args.game) - val properties = mutableListOf().apply { - add( - SubmenuProperty( - R.string.info, - R.string.info_description, - R.drawable.ic_info_outline - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToGameInfoFragment(args.game) - binding.root.findNavController().navigate(action) - } - ) - add( - SubmenuProperty( - R.string.preferences_settings, - R.string.per_game_settings_description, - R.drawable.ic_settings - ) { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - args.game, - Settings.MenuTag.SECTION_ROOT - ) - binding.root.findNavController().navigate(action) - } - ) - - if (GpuDriverHelper.supportsCustomDriverLoading()) { - add( - SubmenuProperty( - R.string.gpu_driver_manager, - R.string.install_gpu_driver_description, - R.drawable.ic_build, - detailsFlow = driverViewModel.selectedDriverTitle - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToDriverManagerFragment(args.game) - binding.root.findNavController().navigate(action) - } - ) - } - - if (!args.game.isHomebrew) { - add( - SubmenuProperty( - R.string.add_ons, - R.string.add_ons_description, - R.drawable.ic_edit - ) { - val action = GamePropertiesFragmentDirections - .actionPerGamePropertiesFragmentToAddonsFragment(args.game) - binding.root.findNavController().navigate(action) - } - ) - add( - InstallableProperty( - R.string.save_data, - R.string.save_data_description, - R.drawable.ic_save, - { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.import_save_warning, - descriptionId = R.string.import_save_warning_description, - positiveAction = { homeViewModel.setOpenImportSaves(true) } - ).show(parentFragmentManager, MessageDialogFragment.TAG) - }, - if (File(args.game.saveDir).exists()) { - { exportSaves.launch(args.game.saveZipName) } - } else { - null - } - ) - ) - - val saveDirFile = File(args.game.saveDir) - if (saveDirFile.exists()) { - add( - SubmenuProperty( - R.string.delete_save_data, - R.string.delete_save_data_description, - R.drawable.ic_delete, - action = { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.delete_save_data, - descriptionId = R.string.delete_save_data_warning_description, - positiveButtonTitleId = android.R.string.cancel, - negativeButtonTitleId = android.R.string.ok, - negativeAction = { - File(args.game.saveDir).deleteRecursively() - Toast.makeText( - YuzuApplication.appContext, - R.string.save_data_deleted_successfully, - Toast.LENGTH_SHORT - ).show() - homeViewModel.reloadPropertiesList(true) - } - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - ) - ) - } - - val shaderCacheDir = File( - DirectoryInitialization.userDirectory + - "/shader/" + args.game.settingsName.lowercase() - ) - if (shaderCacheDir.exists()) { - add( - SubmenuProperty( - R.string.clear_shader_cache, - R.string.clear_shader_cache_description, - R.drawable.ic_delete, - { - if (shaderCacheDir.exists()) { - val bytes = shaderCacheDir.walkTopDown().filter { it.isFile } - .map { it.length() }.sum() - MemoryUtil.bytesToSizeUnit(bytes.toFloat()) - } else { - MemoryUtil.bytesToSizeUnit(0f) - } - } - ) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.clear_shader_cache, - descriptionId = R.string.clear_shader_cache_warning_description, - positiveAction = { - shaderCacheDir.deleteRecursively() - Toast.makeText( - YuzuApplication.appContext, - R.string.cleared_shaders_successfully, - Toast.LENGTH_SHORT - ).show() - homeViewModel.reloadPropertiesList(true) - } - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - ) - } - } - } - binding.listProperties.apply { - layoutManager = - GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) - adapter = GamePropertiesAdapter(viewLifecycleOwner, properties) - } - } - - override fun onResume() { - super.onResume() - driverViewModel.updateDriverNameForGame(args.game) - } - - 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 smallLayout = resources.getBoolean(R.bool.small_layout) - if (smallLayout) { - binding.listAll.updateMargins(left = leftInsets, right = rightInsets) - } else { - if (ViewCompat.getLayoutDirection(binding.root) == - ViewCompat.LAYOUT_DIRECTION_LTR - ) { - binding.listAll.updateMargins(right = rightInsets) - binding.iconLayout!!.updateMargins(top = barInsets.top, left = leftInsets) - } else { - binding.listAll.updateMargins(left = leftInsets) - binding.iconLayout!!.updateMargins(top = barInsets.top, right = rightInsets) - } - } - - val fabSpacing = resources.getDimensionPixelSize(R.dimen.spacing_fab) - binding.buttonStart.updateMargins( - left = leftInsets + fabSpacing, - right = rightInsets + fabSpacing, - bottom = barInsets.bottom + fabSpacing - ) - - binding.layoutAll.updatePadding( - top = barInsets.top, - bottom = barInsets.bottom + - resources.getDimensionPixelSize(R.dimen.spacing_bottom_list_fab) - ) - - windowInsets - } - - private val importSaves = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - val savesFolder = File(args.game.saveDir) - val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") - cacheSaveDir.mkdir() - - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.save_files_importing, - false - ) { _, _ -> - try { - FileUtil.unzipToInternalStorage(result.toString(), cacheSaveDir) - val files = cacheSaveDir.listFiles() - var savesFolderFile: File? = null - if (files != null) { - val savesFolderName = args.game.programIdHex - for (file in files) { - if (file.isDirectory && file.name == savesFolderName) { - savesFolderFile = file - break - } - } - } - - if (savesFolderFile != null) { - savesFolder.deleteRecursively() - savesFolder.mkdir() - savesFolderFile.copyRecursively(savesFolder) - savesFolderFile.deleteRecursively() - } - - withContext(Dispatchers.Main) { - if (savesFolderFile == null) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.save_file_invalid_zip_structure, - descriptionId = R.string.save_file_invalid_zip_structure_description - ).show(parentFragmentManager, MessageDialogFragment.TAG) - return@withContext - } - Toast.makeText( - YuzuApplication.appContext, - getString(R.string.save_file_imported_success), - Toast.LENGTH_LONG - ).show() - homeViewModel.reloadPropertiesList(true) - } - - cacheSaveDir.deleteRecursively() - } catch (e: Exception) { - Toast.makeText( - YuzuApplication.appContext, - getString(R.string.fatal_error), - Toast.LENGTH_LONG - ).show() - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } - - /** - * Exports the save file located in the given folder path by creating a zip file and opening a - * file picker to save. - */ - private val exportSaves = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip") - ) { result -> - if (result == null) { - return@registerForActivityResult - } - - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.save_files_exporting, - false - ) { _, _ -> - val saveLocation = args.game.saveDir - val zipResult = FileUtil.zipFromInternalStorage( - File(saveLocation), - saveLocation.replaceAfterLast("/", ""), - BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)), - compression = false - ) - return@newInstance when (zipResult) { - TaskState.Completed -> getString(R.string.export_success) - TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt deleted file mode 100644 index 14a2504b6..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt +++ /dev/null @@ -1,437 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.Manifest -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Bundle -import android.provider.DocumentsContract -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.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.recyclerview.widget.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.BuildConfig -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter -import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding -import org.yuzu.yuzu_emu.features.DocumentProvider -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.model.HomeSetting -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.GpuDriverHelper -import org.yuzu.yuzu_emu.utils.Log -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -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() - - 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?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) - homeViewModel.setStatusBarShadeVisibility(visible = true) - mainActivity = requireActivity() as MainActivity - - val optionsList: MutableList = mutableListOf().apply { - add( - HomeSetting( - R.string.advanced_settings, - R.string.settings_description, - R.drawable.ic_settings, - { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_ROOT - ) - binding.root.findNavController().navigate(action) - } - ) - ) - add( - HomeSetting( - R.string.preferences_controls, - R.string.preferences_controls_description, - R.drawable.ic_controller, - { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_INPUT - ) - binding.root.findNavController().navigate(action) - } - ) - ) - add( - HomeSetting( - R.string.gpu_driver_manager, - R.string.install_gpu_driver_description, - R.drawable.ic_build, - { - val action = HomeSettingsFragmentDirections - .actionHomeSettingsFragmentToDriverManagerFragment(null) - binding.root.findNavController().navigate(action) - }, - { GpuDriverHelper.supportsCustomDriverLoading() }, - R.string.custom_driver_not_supported, - R.string.custom_driver_not_supported_description, - driverViewModel.selectedDriverTitle - ) - ) - add( - HomeSetting( - R.string.applets, - R.string.applets_description, - R.drawable.ic_applet, - { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_appletLauncherFragment) - }, - { NativeLibrary.isFirmwareAvailable() }, - R.string.applets_error_firmware, - R.string.applets_error_description - ) - ) - add( - HomeSetting( - R.string.manage_yuzu_data, - R.string.manage_yuzu_data_description, - R.drawable.ic_install, - { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_installableFragment) - } - ) - ) - add( - HomeSetting( - R.string.manage_game_folders, - R.string.select_games_folder_description, - R.drawable.ic_add, - { - binding.root.findNavController() - .navigate(R.id.action_homeSettingsFragment_to_gameFoldersFragment) - } - ) - ) - add( - HomeSetting( - R.string.verify_installed_content, - R.string.verify_installed_content_description, - R.drawable.ic_check_circle, - { - ProgressDialogFragment.newInstance( - requireActivity(), - titleId = R.string.verifying, - cancellable = true - ) { progressCallback, _ -> - val result = NativeLibrary.verifyInstalledContents(progressCallback) - return@newInstance if (progressCallback.invoke(100, 100)) { - // Invoke the progress callback to check if the process was cancelled - MessageDialogFragment.newInstance( - titleId = R.string.verify_no_result, - descriptionId = R.string.verify_no_result_description - ) - } else if (result.isEmpty()) { - MessageDialogFragment.newInstance( - titleId = R.string.verify_success, - descriptionId = R.string.operation_completed_successfully - ) - } else { - val failedNames = result.joinToString("\n") - val errorMessage = YuzuApplication.appContext.getString( - R.string.verification_failed_for, - failedNames - ) - MessageDialogFragment.newInstance( - titleId = R.string.verify_failure, - descriptionString = errorMessage - ) - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } - ) - ) - add( - HomeSetting( - R.string.share_log, - R.string.share_log_description, - R.drawable.ic_log, - { shareLog() } - ) - ) - add( - HomeSetting( - R.string.open_user_folder, - R.string.open_user_folder_description, - R.drawable.ic_folder_open, - { openFileManager() } - ) - ) - add( - HomeSetting( - R.string.preferences_theme, - R.string.theme_and_color_description, - R.drawable.ic_palette, - { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_THEME - ) - binding.root.findNavController().navigate(action) - } - ) - ) - add( - HomeSetting( - R.string.about, - R.string.about_description, - R.drawable.ic_info_outline, - { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - parentFragmentManager.primaryNavigationFragment?.findNavController() - ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) - } - ) - ) - } - - if (!BuildConfig.PREMIUM) { - optionsList.add( - 0, - HomeSetting( - R.string.get_early_access, - R.string.get_early_access_description, - R.drawable.ic_diamond, - { - exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - parentFragmentManager.primaryNavigationFragment?.findNavController() - ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) - } - ) - ) - } - - binding.homeSettingsList.apply { - layoutManager = - GridLayoutManager(requireContext(), resources.getInteger(R.integer.grid_columns)) - adapter = HomeSettingAdapter( - requireActivity() as AppCompatActivity, - viewLifecycleOwner, - optionsList - ) - } - - setInsets() - } - - override fun onStart() { - super.onStart() - exitTransition = null - } - - override fun onResume() { - super.onResume() - driverViewModel.updateDriverNameForGame(null) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun openFileManager() { - // First, try to open the user data folder directly - try { - startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) - return - } catch (_: ActivityNotFoundException) { - } - - try { - startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) - return - } catch (_: ActivityNotFoundException) { - } - - // Just try to open the file manager, try the package name used on "normal" phones - try { - startActivity(getFileManagerIntent("com.google.android.documentsui")) - showNoLinkNotification() - return - } catch (_: ActivityNotFoundException) { - } - - try { - // Next, try the AOSP package name - startActivity(getFileManagerIntent("com.android.documentsui")) - showNoLinkNotification() - return - } catch (_: ActivityNotFoundException) { - } - - Toast.makeText( - requireContext(), - resources.getString(R.string.no_file_manager), - Toast.LENGTH_LONG - ).show() - } - - private fun getFileManagerIntent(packageName: String): Intent { - // Fragile, but some phones don't expose the system file manager in any better way - val intent = Intent(Intent.ACTION_MAIN) - intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - return intent - } - - private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { - val authority = "${requireContext().packageName}.user" - val intent = Intent(action) - intent.addCategory(Intent.CATEGORY_DEFAULT) - intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) - intent.addFlags( - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - ) - return intent - } - - private fun showNoLinkNotification() { - val builder = NotificationCompat.Builder( - requireContext(), - getString(R.string.notice_notification_channel_id) - ) - .setSmallIcon(R.drawable.ic_stat_notification_logo) - .setContentTitle(getString(R.string.notification_no_directory_link)) - .setContentText(getString(R.string.notification_no_directory_link_description)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) - // TODO: Make the click action for this notification lead to a help article - - with(NotificationManagerCompat.from(requireContext())) { - if (ActivityCompat.checkSelfPermission( - requireContext(), - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - Toast.makeText( - requireContext(), - resources.getString(R.string.notification_permission_not_granted), - Toast.LENGTH_LONG - ).show() - return - } - notify(0, builder.build()) - } - } - - // Share the current log if we just returned from a game but share the old log - // if we just started the app and the old log exists. - private fun shareLog() { - val currentLog = DocumentFile.fromSingleUri( - mainActivity, - DocumentsContract.buildDocumentUri( - DocumentProvider.AUTHORITY, - "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt" - ) - )!! - val oldLog = DocumentFile.fromSingleUri( - mainActivity, - DocumentsContract.buildDocumentUri( - DocumentProvider.AUTHORITY, - "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt.old.txt" - ) - )!! - - val intent = Intent(Intent.ACTION_SEND) - .setDataAndType(currentLog.uri, FileUtil.TEXT_PLAIN) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - if (!Log.gameLaunched && oldLog.exists()) { - intent.putExtra(Intent.EXTRA_STREAM, oldLog.uri) - startActivity(Intent.createChooser(intent, getText(R.string.share_log))) - } else if (currentLog.exists()) { - 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_missing), - 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 - ) - - binding.scrollViewSettings.updateMargins(left = leftInsets, right = rightInsets) - - binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) - - if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) - } else { - binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) - } - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt deleted file mode 100644 index d218da1c8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/InstallableFragment.kt +++ /dev/null @@ -1,323 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -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.GridLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.InstallableAdapter -import org.yuzu.yuzu_emu.databinding.FragmentInstallablesBinding -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.Installable -import org.yuzu.yuzu_emu.model.TaskState -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.FileUtil -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect -import java.io.BufferedOutputStream -import java.io.File -import java.math.BigInteger -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -class InstallableFragment : Fragment() { - private var _binding: FragmentInstallablesBinding? = 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 = FragmentInstallablesBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val mainActivity = requireActivity() as MainActivity - - homeViewModel.setNavigationVisibility(visible = false, animated = true) - homeViewModel.setStatusBarShadeVisibility(visible = false) - - binding.toolbarInstallables.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() - } - - homeViewModel.openImportSaves.collect(viewLifecycleOwner) { - if (it) { - importSaves.launch(arrayOf("application/zip")) - homeViewModel.setOpenImportSaves(false) - } - } - - val installables = listOf( - Installable( - R.string.user_data, - R.string.user_data_description, - install = { mainActivity.importUserData.launch(arrayOf("application/zip")) }, - export = { mainActivity.exportUserData.launch("export.zip") } - ), - Installable( - R.string.manage_save_data, - R.string.manage_save_data_description, - install = { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.import_save_warning, - descriptionId = R.string.import_save_warning_description, - positiveAction = { homeViewModel.setOpenImportSaves(true) } - ).show(parentFragmentManager, MessageDialogFragment.TAG) - }, - export = { - val oldSaveDataFolder = File( - "${DirectoryInitialization.userDirectory}/nand" + - NativeLibrary.getDefaultProfileSaveDataRoot(false) - ) - val futureSaveDataFolder = File( - "${DirectoryInitialization.userDirectory}/nand" + - NativeLibrary.getDefaultProfileSaveDataRoot(true) - ) - if (!oldSaveDataFolder.exists() && !futureSaveDataFolder.exists()) { - Toast.makeText( - YuzuApplication.appContext, - R.string.no_save_data_found, - Toast.LENGTH_SHORT - ).show() - return@Installable - } else { - exportSaves.launch( - "${getString(R.string.save_data)} " + - LocalDateTime.now().format( - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") - ) - ) - } - } - ), - Installable( - R.string.install_game_content, - R.string.install_game_content_description, - install = { mainActivity.installGameUpdate.launch(arrayOf("*/*")) } - ), - Installable( - R.string.install_firmware, - R.string.install_firmware_description, - install = { mainActivity.getFirmware.launch(arrayOf("application/zip")) } - ), - Installable( - R.string.install_prod_keys, - R.string.install_prod_keys_description, - install = { mainActivity.getProdKey.launch(arrayOf("*/*")) } - ), - Installable( - R.string.install_amiibo_keys, - R.string.install_amiibo_keys_description, - install = { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) } - ) - ) - - binding.listInstallables.apply { - layoutManager = GridLayoutManager( - requireContext(), - resources.getInteger(R.integer.grid_columns) - ) - adapter = InstallableAdapter(installables) - } - - setInsets() - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.toolbarInstallables.updateMargins(left = leftInsets, right = rightInsets) - binding.listInstallables.updateMargins(left = leftInsets, right = rightInsets) - - binding.listInstallables.updatePadding(bottom = barInsets.bottom) - - windowInsets - } - - private val importSaves = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") - cacheSaveDir.mkdir() - - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.save_files_importing, - false - ) { progressCallback, _ -> - try { - FileUtil.unzipToInternalStorage( - result.toString(), - cacheSaveDir, - progressCallback - ) - val files = cacheSaveDir.listFiles() - var successfulImports = 0 - var failedImports = 0 - if (files != null) { - for (file in files) { - if (file.isDirectory) { - val baseSaveDir = - NativeLibrary.getSavePath(BigInteger(file.name, 16).toString()) - if (baseSaveDir.isEmpty()) { - failedImports++ - continue - } - - val internalSaveFolder = File( - "${DirectoryInitialization.userDirectory}/nand$baseSaveDir" - ) - internalSaveFolder.deleteRecursively() - internalSaveFolder.mkdir() - file.copyRecursively(target = internalSaveFolder, overwrite = true) - successfulImports++ - } - } - } - - withContext(Dispatchers.Main) { - if (successfulImports == 0) { - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.save_file_invalid_zip_structure, - descriptionId = R.string.save_file_invalid_zip_structure_description - ).show(parentFragmentManager, MessageDialogFragment.TAG) - return@withContext - } - val successString = if (failedImports > 0) { - """ - ${ - requireContext().resources.getQuantityString( - R.plurals.saves_import_success, - successfulImports, - successfulImports - ) - } - ${ - requireContext().resources.getQuantityString( - R.plurals.saves_import_failed, - failedImports, - failedImports - ) - } - """ - } else { - requireContext().resources.getQuantityString( - R.plurals.saves_import_success, - successfulImports, - successfulImports - ) - } - MessageDialogFragment.newInstance( - requireActivity(), - titleId = R.string.import_complete, - descriptionString = successString - ).show(parentFragmentManager, MessageDialogFragment.TAG) - } - - cacheSaveDir.deleteRecursively() - } catch (e: Exception) { - Toast.makeText( - YuzuApplication.appContext, - getString(R.string.fatal_error), - Toast.LENGTH_LONG - ).show() - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } - - private val exportSaves = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip") - ) { result -> - if (result == null) { - return@registerForActivityResult - } - - ProgressDialogFragment.newInstance( - requireActivity(), - R.string.save_files_exporting, - false - ) { _, _ -> - val cacheSaveDir = File("${requireContext().cacheDir.path}/saves/") - cacheSaveDir.mkdir() - - val oldSaveDataFolder = File( - "${DirectoryInitialization.userDirectory}/nand" + - NativeLibrary.getDefaultProfileSaveDataRoot(false) - ) - if (oldSaveDataFolder.exists()) { - oldSaveDataFolder.copyRecursively(cacheSaveDir) - } - - val futureSaveDataFolder = File( - "${DirectoryInitialization.userDirectory}/nand" + - NativeLibrary.getDefaultProfileSaveDataRoot(true) - ) - if (futureSaveDataFolder.exists()) { - futureSaveDataFolder.copyRecursively(cacheSaveDir) - } - - val saveFilesTotal = cacheSaveDir.listFiles()?.size ?: 0 - if (saveFilesTotal == 0) { - cacheSaveDir.deleteRecursively() - return@newInstance getString(R.string.no_save_data_found) - } - - val zipResult = FileUtil.zipFromInternalStorage( - cacheSaveDir, - cacheSaveDir.path, - BufferedOutputStream(requireContext().contentResolver.openOutputStream(result)) - ) - cacheSaveDir.deleteRecursively() - - return@newInstance when (zipResult) { - TaskState.Completed -> getString(R.string.export_success) - TaskState.Cancelled, TaskState.Failed -> getString(R.string.export_failed) - } - }.show(parentFragmentManager, ProgressDialogFragment.TAG) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt deleted file mode 100644 index e1ac46c48..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LaunchGameDialogFragment.kt +++ /dev/null @@ -1,61 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import androidx.navigation.fragment.findNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable - -class LaunchGameDialogFragment : DialogFragment() { - private var selectedItem = 1 - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val game = requireArguments().parcelable(GAME) - val launchOptions = arrayOf(getString(R.string.global), getString(R.string.custom)) - - if (savedInstanceState != null) { - selectedItem = savedInstanceState.getInt(SELECTED_ITEM) - } - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.launch_options) - .setPositiveButton(android.R.string.ok) { _: DialogInterface, _: Int -> - val action = HomeNavigationDirections - .actionGlobalEmulationActivity(game, selectedItem != 0) - requireParentFragment().findNavController().navigate(action) - } - .setSingleChoiceItems(launchOptions, 1) { _: DialogInterface, i: Int -> - selectedItem = i - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putInt(SELECTED_ITEM, selectedItem) - } - - companion object { - const val TAG = "LaunchGameDialogFragment" - - const val GAME = "Game" - const val SELECTED_ITEM = "SelectedItem" - - fun newInstance(game: Game): LaunchGameDialogFragment { - val args = Bundle() - args.putParcelable(GAME, game) - val fragment = LaunchGameDialogFragment() - fragment.arguments = args - return fragment - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt deleted file mode 100644 index 78419191c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_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.yuzu.yuzu_emu.databinding.DialogLicenseBinding -import org.yuzu.yuzu_emu.model.License -import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable - -class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { - private var _binding: DialogLicenseBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = DialogLicenseBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - BottomSheetBehavior.from(view.parent as View).state = - BottomSheetBehavior.STATE_HALF_EXPANDED - - val license = requireArguments().parcelable(LICENSE)!! - - binding.apply { - textTitle.setText(license.titleId) - textLink.setText(license.linkId) - textCopyright.setText(license.copyrightId) - textLicense.setText(license.licenseId) - } - } - - companion object { - const val TAG = "LicenseBottomSheetDialogFragment" - - const val LICENSE = "License" - - fun newInstance( - license: License - ): LicenseBottomSheetDialogFragment { - val dialog = LicenseBottomSheetDialogFragment() - val bundle = Bundle() - bundle.putParcelable(LICENSE, license) - dialog.arguments = bundle - return dialog - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt deleted file mode 100644 index f17f621f8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.navigation.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.transition.MaterialSharedAxis -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.LicenseAdapter -import org.yuzu.yuzu_emu.databinding.FragmentLicensesBinding -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.License -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins - -class LicensesFragment : Fragment() { - private var _binding: FragmentLicensesBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) - returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentLicensesBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - homeViewModel.setNavigationVisibility(visible = false, animated = true) - homeViewModel.setStatusBarShadeVisibility(visible = false) - - binding.toolbarLicenses.setNavigationOnClickListener { - binding.root.findNavController().popBackStack() - } - - val licenses = listOf( - License( - R.string.license_fidelityfx_fsr, - R.string.license_fidelityfx_fsr_description, - R.string.license_fidelityfx_fsr_link, - R.string.license_fidelityfx_fsr_copyright, - R.string.license_fidelityfx_fsr_text - ), - License( - R.string.license_cubeb, - R.string.license_cubeb_description, - R.string.license_cubeb_link, - R.string.license_cubeb_copyright, - R.string.license_cubeb_text - ), - License( - R.string.license_dynarmic, - R.string.license_dynarmic_description, - R.string.license_dynarmic_link, - R.string.license_dynarmic_copyright, - R.string.license_dynarmic_text - ), - License( - R.string.license_ffmpeg, - R.string.license_ffmpeg_description, - R.string.license_ffmpeg_link, - R.string.license_ffmpeg_copyright, - R.string.license_ffmpeg_text - ), - License( - R.string.license_opus, - R.string.license_opus_description, - R.string.license_opus_link, - R.string.license_opus_copyright, - R.string.license_opus_text - ), - License( - R.string.license_sirit, - R.string.license_sirit_description, - R.string.license_sirit_link, - R.string.license_sirit_copyright, - R.string.license_sirit_text - ), - License( - R.string.license_adreno_tools, - R.string.license_adreno_tools_description, - R.string.license_adreno_tools_link, - R.string.license_adreno_tools_copyright, - R.string.license_adreno_tools_text - ) - ) - - binding.listLicenses.apply { - layoutManager = LinearLayoutManager(requireContext()) - adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses) - } - - setInsets() - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - - binding.appbarLicenses.updateMargins(left = leftInsets, right = rightInsets) - binding.listLicenses.updateMargins(left = leftInsets, right = rightInsets) - - binding.listLicenses.updatePadding(bottom = barInsets.bottom) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt deleted file mode 100644 index c370964e1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.text.Html -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.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.model.MessageDialogViewModel -import org.yuzu.yuzu_emu.utils.Log - -class MessageDialogFragment : DialogFragment() { - private val messageDialogViewModel: MessageDialogViewModel by activityViewModels() - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val titleId = requireArguments().getInt(TITLE_ID) - val title = if (titleId != 0) { - getString(titleId) - } else { - requireArguments().getString(TITLE_STRING)!! - } - - val descriptionId = requireArguments().getInt(DESCRIPTION_ID) - val description = if (descriptionId != 0) { - getString(descriptionId) - } else { - requireArguments().getString(DESCRIPTION_STRING)!! - } - - val positiveButtonId = requireArguments().getInt(POSITIVE_BUTTON_TITLE_ID) - val positiveButtonString = requireArguments().getString(POSITIVE_BUTTON_TITLE_STRING)!! - val positiveButton = if (positiveButtonId != 0) { - getString(positiveButtonId) - } else if (positiveButtonString.isNotEmpty()) { - positiveButtonString - } else if (messageDialogViewModel.positiveAction != null) { - getString(android.R.string.ok) - } else { - getString(R.string.close) - } - - val negativeButtonId = requireArguments().getInt(NEGATIVE_BUTTON_TITLE_ID) - val negativeButtonString = requireArguments().getString(NEGATIVE_BUTTON_TITLE_STRING)!! - val negativeButton = if (negativeButtonId != 0) { - getString(negativeButtonId) - } else if (negativeButtonString.isNotEmpty()) { - negativeButtonString - } else { - getString(android.R.string.cancel) - } - - val helpLinkId = requireArguments().getInt(HELP_LINK) - val dismissible = requireArguments().getBoolean(DISMISSIBLE) - val clearPositiveAction = requireArguments().getBoolean(CLEAR_ACTIONS) - val showNegativeButton = requireArguments().getBoolean(SHOW_NEGATIVE_BUTTON) - - val builder = MaterialAlertDialogBuilder(requireContext()) - - if (clearPositiveAction) { - messageDialogViewModel.positiveAction = null - } - - builder.setPositiveButton(positiveButton) { _, _ -> - messageDialogViewModel.positiveAction?.invoke() - } - if (messageDialogViewModel.negativeAction != null || showNegativeButton) { - builder.setNegativeButton(negativeButton) { _, _ -> - messageDialogViewModel.negativeAction?.invoke() - } - } - - if (title.isNotEmpty()) builder.setTitle(title) - if (description.isNotEmpty()) { - builder.setMessage(Html.fromHtml(description, Html.FROM_HTML_MODE_LEGACY)) - } - - if (helpLinkId != 0) { - builder.setNeutralButton(R.string.learn_more) { _, _ -> - openLink(getString(helpLinkId)) - } - } - - isCancelable = dismissible - - return builder.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 TITLE_STRING = "TitleString" - private const val DESCRIPTION_ID = "DescriptionId" - private const val DESCRIPTION_STRING = "DescriptionString" - private const val HELP_LINK = "Link" - private const val DISMISSIBLE = "Dismissible" - private const val CLEAR_ACTIONS = "ClearActions" - private const val POSITIVE_BUTTON_TITLE_ID = "PositiveButtonTitleId" - private const val POSITIVE_BUTTON_TITLE_STRING = "PositiveButtonTitleString" - private const val SHOW_NEGATIVE_BUTTON = "ShowNegativeButton" - private const val NEGATIVE_BUTTON_TITLE_ID = "NegativeButtonTitleId" - private const val NEGATIVE_BUTTON_TITLE_STRING = "NegativeButtonTitleString" - - /** - * Creates a new [MessageDialogFragment] instance. - * @param activity Activity that will hold a [MessageDialogViewModel] instance if using - * [positiveAction] or [negativeAction]. - * @param titleId String resource ID that will be used for the title. [titleString] used if 0. - * @param titleString String that will be used for the title. No title is set if empty. - * @param descriptionId String resource ID that will be used for the description. - * [descriptionString] used if 0. - * @param descriptionString String that will be used for the description. - * No description is set if empty. - * @param helpLinkId String resource ID that contains a help link. Will be added as a neutral - * button with the title R.string.help. - * @param dismissible Whether the dialog is dismissible or not. Typically used to ensure that - * the user clicks on one of the dialog buttons before closing. - * @param positiveButtonTitleId String resource ID that will be used for the positive button. - * [positiveButtonTitleString] used if 0. - * @param positiveButtonTitleString String that will be used for the positive button. - * android.R.string.close used if empty. android.R.string.ok will be used if [positiveAction] - * is not null. - * @param positiveAction Lambda to run when the positive button is clicked. - * @param showNegativeButton Normally the negative button isn't shown if there is no - * [negativeAction] set. This can override that behavior to always show a button. - * @param negativeButtonTitleId String resource ID that will be used for the negative button. - * [negativeButtonTitleString] used if 0. - * @param negativeButtonTitleString String that will be used for the negative button. - * android.R.string.cancel used if empty. - * @param negativeAction Lambda to run when the negative button is clicked - */ - fun newInstance( - activity: FragmentActivity? = null, - titleId: Int = 0, - titleString: String = "", - descriptionId: Int = 0, - descriptionString: String = "", - helpLinkId: Int = 0, - dismissible: Boolean = true, - positiveButtonTitleId: Int = 0, - positiveButtonTitleString: String = "", - positiveAction: (() -> Unit)? = null, - showNegativeButton: Boolean = false, - negativeButtonTitleId: Int = 0, - negativeButtonTitleString: String = "", - negativeAction: (() -> Unit)? = null - ): MessageDialogFragment { - var clearActions = false - if (activity != null) { - ViewModelProvider(activity)[MessageDialogViewModel::class.java].apply { - clear() - this.positiveAction = positiveAction - this.negativeAction = negativeAction - } - } else { - clearActions = true - } - - if (activity == null && (positiveAction == null || negativeAction == null)) { - Log.warning("[$TAG] Tried to set action with no activity!") - } - - val dialog = MessageDialogFragment() - val bundle = Bundle().apply { - putInt(TITLE_ID, titleId) - putString(TITLE_STRING, titleString) - putInt(DESCRIPTION_ID, descriptionId) - putString(DESCRIPTION_STRING, descriptionString) - putInt(HELP_LINK, helpLinkId) - putBoolean(DISMISSIBLE, dismissible) - putBoolean(CLEAR_ACTIONS, clearActions) - putInt(POSITIVE_BUTTON_TITLE_ID, positiveButtonTitleId) - putString(POSITIVE_BUTTON_TITLE_STRING, positiveButtonTitleString) - putBoolean(SHOW_NEGATIVE_BUTTON, showNegativeButton) - putInt(NEGATIVE_BUTTON_TITLE_ID, negativeButtonTitleId) - putString(NEGATIVE_BUTTON_TITLE_STRING, negativeButtonTitleString) - } - dialog.arguments = bundle - return dialog - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt deleted file mode 100644 index 3478b9250..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.provider.Settings -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R - -class PermissionDeniedDialogFragment : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - return MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int -> - openSettings() - } - .setNegativeButton(android.R.string.cancel, null) - .setTitle(R.string.permission_denied) - .setMessage(R.string.permission_denied_description) - .show() - } - - private fun openSettings() { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - val uri = Uri.fromParts("package", requireActivity().packageName, null) - intent.data = uri - startActivity(intent) - } - - companion object { - const val TAG = "PermissionDeniedDialogFragment" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt deleted file mode 100644 index ee3bb0386..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ProgressDialogFragment.kt +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.os.Bundle -import 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.ViewModelProvider -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding -import org.yuzu.yuzu_emu.model.TaskViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect - -class ProgressDialogFragment : DialogFragment() { - private val taskViewModel: TaskViewModel by activityViewModels() - - private lateinit var binding: DialogProgressBarBinding - - private val PROGRESS_BAR_RESOLUTION = 1000 - - 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) - binding.message.isSelected = true - taskViewModel.isComplete.collect(viewLifecycleOwner) { - 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() - } - } - taskViewModel.cancelled.collect(viewLifecycleOwner) { - if (it) { - dialog?.setTitle(R.string.cancelling) - } - } - taskViewModel.progress.collect(viewLifecycleOwner) { - if (it != 0.0) { - binding.progressBar.apply { - isIndeterminate = false - progress = ( - (it / taskViewModel.maxProgress.value) * - PROGRESS_BAR_RESOLUTION - ).toInt() - min = 0 - max = PROGRESS_BAR_RESOLUTION - } - } - } - taskViewModel.message.collect(viewLifecycleOwner) { - binding.message.setVisible(it.isNotEmpty()) - binding.message.text = it - } - } - - // 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)) - binding.progressBar.isIndeterminate = true - 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: suspend ( - progressCallback: (max: Long, progress: Long) -> Boolean, - messageCallback: (message: String) -> Unit - ) -> Any - ): ProgressDialogFragment { - val dialog = ProgressDialogFragment() - 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/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt deleted file mode 100644 index 1b4b93ab8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity - -class ResetSettingsDialogFragment : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val settingsActivity = requireActivity() as SettingsActivity - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.reset_all_settings) - .setMessage(R.string.reset_all_settings_description) - .setPositiveButton(android.R.string.ok) { _, _ -> - settingsActivity.onSettingsReset() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - companion object { - const val TAG = "ResetSettingsDialogFragment" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt deleted file mode 100644 index 662ae9760..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt +++ /dev/null @@ -1,218 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.InputMethodManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.preference.PreferenceManager -import info.debatty.java.stringsimilarity.Jaccard -import info.debatty.java.stringsimilarity.JaroWinkler -import java.util.Locale -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.GameAdapter -import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding -import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect - -class SearchFragment : Fragment() { - private var _binding: FragmentSearchBinding? = null - private val binding get() = _binding!! - - private val gamesViewModel: GamesViewModel by activityViewModels() - private val homeViewModel: HomeViewModel by activityViewModels() - - private lateinit var preferences: SharedPreferences - - companion object { - private const val SEARCH_TEXT = "SearchText" - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSearchBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) - homeViewModel.setStatusBarShadeVisibility(true) - preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - - if (savedInstanceState != null) { - binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) - } - - binding.gridGamesSearch.apply { - layoutManager = AutofitGridLayoutManager( - requireContext(), - requireContext().resources.getDimensionPixelSize(R.dimen.card_width) - ) - adapter = GameAdapter(requireActivity() as AppCompatActivity) - } - - binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } - - binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> - binding.clearButton.setVisible(text.toString().isNotEmpty()) - filterAndSearch() - } - - gamesViewModel.searchFocused.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setSearchFocused(false) } - ) { if (it) focusSearch() } - gamesViewModel.games.collect(viewLifecycleOwner) { filterAndSearch() } - gamesViewModel.searchedGames.collect(viewLifecycleOwner) { - (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) - binding.noResultsView.setVisible(it.isNotEmpty()) - } - - binding.clearButton.setOnClickListener { binding.searchText.setText("") } - - binding.searchBackground.setOnClickListener { focusSearch() } - - setInsets() - filterAndSearch() - } - - private inner class ScoredGame(val score: Double, val item: Game) - - private fun filterAndSearch() { - val baseList = gamesViewModel.games.value - val filteredList: List = when (binding.chipGroup.checkedChipId) { - R.id.chip_recently_played -> { - baseList.filter { - val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) - lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyLastPlayedTime, 0L) } - } - - R.id.chip_recently_added -> { - baseList.filter { - val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) - addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) - }.sortedByDescending { preferences.getLong(it.keyAddedToLibraryTime, 0L) } - } - - R.id.chip_homebrew -> baseList.filter { it.isHomebrew } - - R.id.chip_retail -> baseList.filter { !it.isHomebrew } - - else -> baseList - } - - if (binding.searchText.text.toString().isEmpty() && - binding.chipGroup.checkedChipId != View.NO_ID - ) { - gamesViewModel.setSearchedGames(filteredList) - return - } - - val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) - val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() - val sortedList: List = filteredList.mapNotNull { game -> - val title = game.title.lowercase(Locale.getDefault()) - val score = searchAlgorithm.similarity(searchTerm, title) - if (score > 0.03) { - ScoredGame(score, game) - } else { - null - } - }.sortedByDescending { it.score }.map { it.item } - gamesViewModel.setSearchedGames(sortedList) - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (_binding != null) { - outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) - } - } - - private fun focusSearch() { - if (_binding != null) { - binding.searchText.requestFocus() - val imm = requireActivity() - .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? - imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) - } - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { view: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) - val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - val spacingNavigationRail = - resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) - - binding.constraintSearch.updatePadding( - left = barInsets.left + cutoutInsets.left, - top = barInsets.top, - right = barInsets.right + cutoutInsets.right - ) - - binding.gridGamesSearch.updatePadding( - top = extraListSpacing, - bottom = barInsets.bottom + spacingNavigation + extraListSpacing - ) - binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) - - val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams - if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - binding.frameSearch.updatePadding(left = spacingNavigationRail) - binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) - binding.noResultsView.updatePadding(left = spacingNavigationRail) - binding.chipGroup.updatePadding( - left = chipSpacing + spacingNavigationRail, - right = chipSpacing - ) - mlpDivider.leftMargin = chipSpacing + spacingNavigationRail - mlpDivider.rightMargin = chipSpacing - } else { - binding.frameSearch.updatePadding(right = spacingNavigationRail) - binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) - binding.noResultsView.updatePadding(right = spacingNavigationRail) - binding.chipGroup.updatePadding( - left = chipSpacing, - right = chipSpacing + spacingNavigationRail - ) - mlpDivider.leftMargin = chipSpacing - mlpDivider.rightMargin = chipSpacing + spacingNavigationRail - } - binding.divider.layoutParams = mlpDivider - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt deleted file mode 100644 index 4f7548e98..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt +++ /dev/null @@ -1,396 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.Manifest -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.OnBackPressedCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible -import androidx.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.transition.MaterialFadeThrough -import kotlinx.coroutines.launch -import org.yuzu.yuzu_emu.NativeLibrary -import java.io.File -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.adapters.SetupAdapter -import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.SetupCallback -import org.yuzu.yuzu_emu.model.SetupPage -import org.yuzu.yuzu_emu.model.StepState -import org.yuzu.yuzu_emu.ui.main.MainActivity -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.NativeConfig -import org.yuzu.yuzu_emu.utils.ViewUtils -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.collect - -class SetupFragment : Fragment() { - private var _binding: FragmentSetupBinding? = null - private val binding get() = _binding!! - - private val homeViewModel: HomeViewModel by activityViewModels() - - private lateinit var mainActivity: MainActivity - - private lateinit var hasBeenWarned: BooleanArray - - companion object { - const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" - const val KEY_BACK_VISIBILITY = "BackButtonVisibility" - const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - exitTransition = MaterialFadeThrough() - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSetupBinding.inflate(layoutInflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - mainActivity = requireActivity() as MainActivity - - homeViewModel.setNavigationVisibility(visible = false, animated = false) - - requireActivity().onBackPressedDispatcher.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.viewPager2.currentItem > 0) { - pageBackward() - } else { - requireActivity().finish() - } - } - } - ) - - requireActivity().window.navigationBarColor = - ContextCompat.getColor(requireContext(), android.R.color.transparent) - - val pages = mutableListOf() - pages.apply { - add( - SetupPage( - R.drawable.ic_yuzu_title, - R.string.welcome, - R.string.welcome_description, - 0, - true, - R.string.get_started, - { pageForward() }, - false - ) - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - add( - SetupPage( - R.drawable.ic_notification, - R.string.notifications, - R.string.notifications_description, - 0, - false, - R.string.give_permission, - { - notificationCallback = it - permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - }, - true, - R.string.notification_warning, - R.string.notification_warning_description, - 0, - { - if (NotificationManagerCompat.from(requireContext()) - .areNotificationsEnabled() - ) { - StepState.COMPLETE - } else { - StepState.INCOMPLETE - } - } - ) - ) - } - - add( - SetupPage( - R.drawable.ic_key, - R.string.keys, - R.string.keys_description, - R.drawable.ic_add, - true, - R.string.select_keys, - { - keyCallback = it - getProdKey.launch(arrayOf("*/*")) - }, - true, - R.string.install_prod_keys_warning, - R.string.install_prod_keys_warning_description, - R.string.install_prod_keys_warning_help, - { - val file = File(DirectoryInitialization.userDirectory + "/keys/prod.keys") - if (file.exists() && NativeLibrary.areKeysPresent()) { - StepState.COMPLETE - } else { - StepState.INCOMPLETE - } - } - ) - ) - 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) - }, - true, - R.string.add_games_warning, - R.string.add_games_warning_description, - R.string.add_games_warning_help, - { - if (NativeConfig.getGameDirs().isNotEmpty()) { - StepState.COMPLETE - } else { - StepState.INCOMPLETE - } - } - ) - ) - add( - SetupPage( - R.drawable.ic_check, - R.string.done, - R.string.done_description, - R.drawable.ic_arrow_forward, - false, - R.string.text_continue, - { finishSetup() }, - false - ) - ) - } - - homeViewModel.shouldPageForward.collect( - viewLifecycleOwner, - resetState = { homeViewModel.setShouldPageForward(false) } - ) { if (it) pageForward() } - homeViewModel.gamesDirSelected.collect( - viewLifecycleOwner, - resetState = { homeViewModel.setGamesDirSelected(false) } - ) { if (it) gamesDirCallback.onStepCompleted() } - - 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) { - val stepState = currentPage.stepCompleted.invoke() - if (stepState != StepState.INCOMPLETE) { - pageForward() - return@setOnClickListener - } - - if (!hasBeenWarned[index]) { - SetupWarningDialogFragment.newInstance( - currentPage.warningTitleId, - currentPage.warningDescriptionId, - currentPage.warningHelpLinkId, - index - ).show(childFragmentManager, SetupWarningDialogFragment.TAG) - return@setOnClickListener - } - } - pageForward() - } - binding.buttonBack.setOnClickListener { pageBackward() } - - if (savedInstanceState != null) { - val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) - val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) - hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! - - binding.buttonNext.setVisible(nextIsVisible) - binding.buttonBack.setVisible(backIsVisible) - } else { - hasBeenWarned = BooleanArray(pages.size) - } - - setInsets() - } - - override fun onStop() { - super.onStop() - NativeConfig.saveGlobalConfig() - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - if (_binding != null) { - 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 - - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - private val permissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - notificationCallback.onStepCompleted() - } - - if (!it && - !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) - ) { - PermissionDeniedDialogFragment().show( - childFragmentManager, - PermissionDeniedDialogFragment.TAG - ) - } - } - - private lateinit var keyCallback: SetupCallback - - val getProdKey = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result != null) { - mainActivity.processKey(result) - if (NativeLibrary.areKeysPresent()) { - keyCallback.onStepCompleted() - } - } - } - - private lateinit var gamesDirCallback: SetupCallback - - val getGamesDirectory = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> - if (result != null) { - mainActivity.processGamesDir(result) - } - } - - private fun finishSetup() { - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() - .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) - .apply() - mainActivity.finishSetup(binding.root.findNavController()) - } - - fun pageForward() { - if (_binding != null) { - binding.viewPager2.currentItem += 1 - } - } - - fun pageBackward() { - if (_binding != null) { - 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 - .updatePadding( - left = leftPadding, - right = rightPadding, - bottom = bottomPadding - ) - } - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt deleted file mode 100644 index b2c1d54af..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt +++ /dev/null @@ -1,86 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.fragments - -import android.app.Dialog -import android.content.DialogInterface -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.yuzu.yuzu_emu.R - -class SetupWarningDialogFragment : DialogFragment() { - private var titleId: Int = 0 - private var descriptionId: Int = 0 - private var helpLinkId: Int = 0 - private var page: Int = 0 - - private lateinit var setupFragment: SetupFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - titleId = requireArguments().getInt(TITLE) - descriptionId = requireArguments().getInt(DESCRIPTION) - helpLinkId = requireArguments().getInt(HELP_LINK) - page = requireArguments().getInt(PAGE) - - setupFragment = requireParentFragment() as SetupFragment - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int -> - setupFragment.pageForward() - setupFragment.setPageWarned(page) - } - .setNegativeButton(R.string.warning_cancel, null) - - if (titleId != 0) { - builder.setTitle(titleId) - } else { - builder.setTitle("") - } - if (descriptionId != 0) { - builder.setMessage(descriptionId) - } - if (helpLinkId != 0) { - builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int -> - val helpLink = resources.getString(R.string.install_prod_keys_warning_help) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink)) - startActivity(intent) - } - } - - return builder.show() - } - - companion object { - const val TAG = "SetupWarningDialogFragment" - - private const val TITLE = "Title" - private const val DESCRIPTION = "Description" - private const val HELP_LINK = "HelpLink" - private const val PAGE = "Page" - - fun newInstance( - titleId: Int, - descriptionId: Int, - helpLinkId: Int, - page: Int - ): SetupWarningDialogFragment { - val dialog = SetupWarningDialogFragment() - val bundle = Bundle() - bundle.apply { - putInt(TITLE, titleId) - putInt(DESCRIPTION, descriptionId) - putInt(HELP_LINK, helpLinkId) - putInt(PAGE, page) - } - dialog.arguments = bundle - return dialog - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt deleted file mode 100644 index bdd6ea628..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.layout - -import android.content.Context -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.Recycler -import org.yuzu.yuzu_emu.R - -/** - * Cut down version of the solution provided here - * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count - */ -class AutofitGridLayoutManager( - context: Context, - columnWidth: Int -) : GridLayoutManager(context, 1) { - private var columnWidth = 0 - private var isColumnWidthChanged = true - private var lastWidth = 0 - private var lastHeight = 0 - - init { - setColumnWidth(checkedColumnWidth(context, columnWidth)) - } - - private fun checkedColumnWidth(context: Context, columnWidth: Int): Int { - var newColumnWidth = columnWidth - if (newColumnWidth <= 0) { - newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) - } - return newColumnWidth - } - - private fun setColumnWidth(newColumnWidth: Int) { - if (newColumnWidth > 0 && newColumnWidth != columnWidth) { - columnWidth = newColumnWidth - isColumnWidthChanged = true - } - } - - override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) { - val width = width - val height = height - if (columnWidth > 0 && width > 0 && height > 0 && - (isColumnWidthChanged || lastWidth != width || lastHeight != height) - ) { - val totalSpace: Int = if (orientation == VERTICAL) { - width - paddingRight - paddingLeft - } else { - height - paddingTop - paddingBottom - } - val spanCount = 1.coerceAtLeast(totalSpace / columnWidth) - setSpanCount(spanCount) - isColumnWidthChanged = false - } - lastWidth = width - lastHeight = height - super.onLayoutChildren(recycler, state) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt deleted file mode 100644 index b9c8e49ca..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/AddonViewModel.kt +++ /dev/null @@ -1,97 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.utils.NativeConfig -import java.util.concurrent.atomic.AtomicBoolean - -class AddonViewModel : ViewModel() { - private val _patchList = MutableStateFlow(mutableListOf()) - val addonList get() = _patchList.asStateFlow() - - private val _showModInstallPicker = MutableStateFlow(false) - val showModInstallPicker get() = _showModInstallPicker.asStateFlow() - - private val _showModNoticeDialog = MutableStateFlow(false) - val showModNoticeDialog get() = _showModNoticeDialog.asStateFlow() - - private val _addonToDelete = MutableStateFlow(null) - val addonToDelete = _addonToDelete.asStateFlow() - - var game: Game? = null - - private val isRefreshing = AtomicBoolean(false) - - fun onOpenAddons(game: Game) { - this.game = game - refreshAddons() - } - - fun refreshAddons() { - if (isRefreshing.get() || game == null) { - return - } - isRefreshing.set(true) - viewModelScope.launch { - withContext(Dispatchers.IO) { - val patchList = ( - NativeLibrary.getPatchesForFile(game!!.path, game!!.programId) - ?: emptyArray() - ).toMutableList() - patchList.sortBy { it.name } - _patchList.value = patchList - isRefreshing.set(false) - } - } - } - - fun setAddonToDelete(patch: Patch?) { - _addonToDelete.value = patch - } - - fun onDeleteAddon(patch: Patch) { - when (PatchType.from(patch.type)) { - PatchType.Update -> NativeLibrary.removeUpdate(patch.programId) - PatchType.DLC -> NativeLibrary.removeDLC(patch.programId) - PatchType.Mod -> NativeLibrary.removeMod(patch.programId, patch.name) - } - refreshAddons() - } - - fun onCloseAddons() { - if (_patchList.value.isEmpty()) { - return - } - - NativeConfig.setDisabledAddons( - game!!.programId, - _patchList.value.mapNotNull { - if (it.enabled) { - null - } else { - it.name - } - }.toTypedArray() - ) - NativeConfig.saveGlobalConfig() - _patchList.value.clear() - game = null - } - - fun showModInstallPicker(install: Boolean) { - _showModInstallPicker.value = install - } - - fun showModNoticeDialog(show: Boolean) { - _showModNoticeDialog.value = show - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Applet.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Applet.kt deleted file mode 100644 index 8677674a3..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Applet.kt +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import org.yuzu.yuzu_emu.R - -data class Applet( - @StringRes val titleId: Int, - @StringRes val descriptionId: Int, - @DrawableRes val iconId: Int, - val appletInfo: AppletInfo, - val cabinetMode: CabinetMode = CabinetMode.None -) - -// Combination of Common::AM::Applets::AppletId enum and the entry id -enum class AppletInfo(val appletId: Int, val entryId: Long = 0) { - None(0x00), - Application(0x01), - OverlayDisplay(0x02), - QLaunch(0x03), - Starter(0x04), - Auth(0x0A), - Cabinet(0x0B, 0x0100000000001002), - Controller(0x0C), - DataErase(0x0D), - Error(0x0E), - NetConnect(0x0F), - ProfileSelect(0x10), - SoftwareKeyboard(0x11), - MiiEdit(0x12, 0x0100000000001009), - Web(0x13), - Shop(0x14), - PhotoViewer(0x015, 0x010000000000100D), - Settings(0x16), - OfflineWeb(0x17), - LoginShare(0x18), - WebAuth(0x19), - MyPage(0x1A) -} - -// Matches enum in Service::NFP::CabinetMode with extra metadata -enum class CabinetMode( - val id: Int, - @StringRes val titleId: Int = 0, - @DrawableRes val iconId: Int = 0 -) { - None(-1), - StartNicknameAndOwnerSettings(0, R.string.cabinet_nickname_and_owner, R.drawable.ic_edit), - StartGameDataEraser(1, R.string.cabinet_game_data_eraser, R.drawable.ic_refresh), - StartRestorer(2, R.string.cabinet_restorer, R.drawable.ic_restore), - StartFormatter(3, R.string.cabinet_formatter, R.drawable.ic_clear) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt deleted file mode 100644 index de342212a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Driver.kt +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import org.yuzu.yuzu_emu.utils.GpuDriverMetadata - -data class Driver( - override var selected: Boolean, - val title: String, - val version: String = "", - val description: String = "" -) : SelectableItem { - override fun onSelectionStateChanged(selected: Boolean) { - this.selected = selected - } - - companion object { - fun GpuDriverMetadata.toDriver(selected: Boolean = false): Driver = - Driver( - selected, - this.name ?: "", - this.version ?: "", - this.description ?: "" - ) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt deleted file mode 100644 index a49c887a1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/DriverViewModel.kt +++ /dev/null @@ -1,196 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile -import org.yuzu.yuzu_emu.model.Driver.Companion.toDriver -import org.yuzu.yuzu_emu.utils.GpuDriverHelper -import org.yuzu.yuzu_emu.utils.GpuDriverMetadata -import org.yuzu.yuzu_emu.utils.NativeConfig -import java.io.File - -class DriverViewModel : ViewModel() { - private val _areDriversLoading = MutableStateFlow(false) - private val _isDriverReady = MutableStateFlow(true) - private val _isDeletingDrivers = MutableStateFlow(false) - - val isInteractionAllowed: StateFlow = - combine( - _areDriversLoading, - _isDriverReady, - _isDeletingDrivers - ) { loading, ready, deleting -> - !loading && ready && !deleting - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), initialValue = false) - - var driverData = GpuDriverHelper.getDrivers() - - private val _driverList = MutableStateFlow(emptyList()) - val driverList: StateFlow> get() = _driverList - - // Used for showing which driver is currently installed within the driver manager card - private val _selectedDriverTitle = MutableStateFlow("") - val selectedDriverTitle: StateFlow get() = _selectedDriverTitle - - private val _showClearButton = MutableStateFlow(false) - val showClearButton = _showClearButton.asStateFlow() - - private val driversToDelete = mutableListOf() - - init { - updateDriverList() - updateDriverNameForGame(null) - } - - fun reloadDriverData() { - _areDriversLoading.value = true - driverData = GpuDriverHelper.getDrivers() - updateDriverList() - _areDriversLoading.value = false - } - - fun updateDriverList() { - val selectedDriver = GpuDriverHelper.customDriverSettingData - val systemDriverData = GpuDriverHelper.getSystemDriverInfo() - val newDriverList = mutableListOf( - Driver( - selectedDriver == GpuDriverMetadata(), - YuzuApplication.appContext.getString(R.string.system_gpu_driver), - systemDriverData?.get(0) ?: "", - systemDriverData?.get(1) ?: "" - ) - ) - driverData.forEach { - newDriverList.add(it.second.toDriver(it.second == selectedDriver)) - } - _driverList.value = newDriverList - } - - fun onOpenDriverManager(game: Game?) { - if (game != null) { - SettingsFile.loadCustomConfig(game) - } - updateDriverList() - } - - fun showClearButton(value: Boolean) { - _showClearButton.value = value - } - - fun onDriverSelected(position: Int) { - if (position == 0) { - StringSetting.DRIVER_PATH.setString("") - } else { - StringSetting.DRIVER_PATH.setString(driverData[position - 1].first) - } - } - - fun onDriverRemoved(removedPosition: Int, selectedPosition: Int) { - driversToDelete.add(driverData[removedPosition - 1].first) - driverData.removeAt(removedPosition - 1) - onDriverSelected(selectedPosition) - } - - fun onDriverAdded(driver: Pair) { - if (driversToDelete.contains(driver.first)) { - driversToDelete.remove(driver.first) - } - driverData.add(driver) - onDriverSelected(driverData.size) - } - - fun onCloseDriverManager(game: Game?) { - _isDeletingDrivers.value = true - updateDriverNameForGame(game) - if (game == null) { - NativeConfig.saveGlobalConfig() - } else { - NativeConfig.savePerGameConfig() - NativeConfig.unloadPerGameConfig() - NativeConfig.reloadGlobalConfig() - } - - viewModelScope.launch { - withContext(Dispatchers.IO) { - driversToDelete.forEach { - val driver = File(it) - if (driver.exists()) { - driver.delete() - } - } - driversToDelete.clear() - _isDeletingDrivers.value = false - } - } - } - - // It is the Emulation Fragment's responsibility to load per-game settings so that this function - // knows what driver to load. - fun onLaunchGame() { - _isDriverReady.value = false - - val selectedDriverFile = File(StringSetting.DRIVER_PATH.getString()) - val selectedDriverMetadata = GpuDriverHelper.customDriverSettingData - if (GpuDriverHelper.installedCustomDriverData == selectedDriverMetadata) { - setDriverReady() - return - } - - viewModelScope.launch { - withContext(Dispatchers.IO) { - if (selectedDriverMetadata.name == null) { - GpuDriverHelper.installDefaultDriver() - setDriverReady() - return@withContext - } - - if (selectedDriverFile.exists()) { - GpuDriverHelper.installCustomDriver(selectedDriverFile) - } else { - GpuDriverHelper.installDefaultDriver() - } - setDriverReady() - } - } - } - - fun updateDriverNameForGame(game: Game?) { - if (!GpuDriverHelper.supportsCustomDriverLoading()) { - return - } - - if (game == null || NativeConfig.isPerGameConfigLoaded()) { - updateName() - } else { - SettingsFile.loadCustomConfig(game) - updateName() - NativeConfig.unloadPerGameConfig() - NativeConfig.reloadGlobalConfig() - } - } - - private fun updateName() { - _selectedDriverTitle.value = GpuDriverHelper.customDriverSettingData.name - ?: YuzuApplication.appContext.getString(R.string.system_gpu_driver) - } - - private fun setDriverReady() { - _isDriverReady.value = true - updateName() - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/EmulationViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/EmulationViewModel.kt deleted file mode 100644 index d024493cd..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/EmulationViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -class EmulationViewModel : ViewModel() { - val emulationStarted: StateFlow get() = _emulationStarted - private val _emulationStarted = MutableStateFlow(false) - - val isEmulationStopping: StateFlow get() = _isEmulationStopping - private val _isEmulationStopping = MutableStateFlow(false) - - private val _emulationStopped = MutableStateFlow(false) - val emulationStopped = _emulationStopped.asStateFlow() - - private val _programChanged = MutableStateFlow(-1) - val programChanged = _programChanged.asStateFlow() - - val shaderProgress: StateFlow get() = _shaderProgress - private val _shaderProgress = MutableStateFlow(0) - - val totalShaders: StateFlow get() = _totalShaders - private val _totalShaders = MutableStateFlow(0) - - val shaderMessage: StateFlow get() = _shaderMessage - private val _shaderMessage = MutableStateFlow("") - - private val _drawerOpen = MutableStateFlow(false) - val drawerOpen = _drawerOpen.asStateFlow() - - fun setEmulationStarted(started: Boolean) { - _emulationStarted.value = started - } - - fun setIsEmulationStopping(value: Boolean) { - _isEmulationStopping.value = value - } - - fun setEmulationStopped(value: Boolean) { - if (value) { - _emulationStarted.value = false - } - _emulationStopped.value = value - } - - fun setProgramChanged(programIndex: Int) { - _programChanged.value = programIndex - } - - fun setShaderProgress(progress: Int) { - _shaderProgress.value = progress - } - - fun setTotalShaders(max: Int) { - _totalShaders.value = max - } - - fun setShaderMessage(msg: String) { - _shaderMessage.value = msg - } - - fun updateProgress(msg: String, progress: Int, max: Int) { - setShaderMessage(msg) - setShaderProgress(progress) - setTotalShaders(max) - } - - fun setDrawerOpen(value: Boolean) { - _drawerOpen.value = value - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt deleted file mode 100644 index 6859b7780..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.content.Intent -import android.net.Uri -import android.os.Parcelable -import java.util.HashSet -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.activities.EmulationActivity -import org.yuzu.yuzu_emu.utils.DirectoryInitialization -import org.yuzu.yuzu_emu.utils.FileUtil -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter - -@Parcelize -@Serializable -class Game( - val title: String = "", - val path: String, - val programId: String = "", - val developer: String = "", - var version: String = "", - val isHomebrew: Boolean = false -) : Parcelable { - val keyAddedToLibraryTime get() = "${path}_AddedToLibraryTime" - val keyLastPlayedTime get() = "${path}_LastPlayed" - - val settingsName: String - get() { - val programIdLong = programId.toLong() - return if (programIdLong == 0L) { - FileUtil.getFilename(Uri.parse(path)) - } else { - "0" + programIdLong.toString(16).uppercase() - } - } - - val programIdHex: String - get() { - val programIdLong = programId.toLong() - return if (programIdLong == 0L) { - "0" - } else { - "0" + programIdLong.toString(16).uppercase() - } - } - - val saveZipName: String - get() = "$title ${YuzuApplication.appContext.getString(R.string.save_data).lowercase()} - ${ - LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) - }.zip" - - val saveDir: String - get() = DirectoryInitialization.userDirectory + "/nand" + - NativeLibrary.getSavePath(programId) - - val addonDir: String - get() = DirectoryInitialization.userDirectory + "/load/" + programIdHex + "/" - - val launchIntent: Intent - get() = Intent(YuzuApplication.appContext, EmulationActivity::class.java).apply { - action = Intent.ACTION_VIEW - data = Uri.parse(path) - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as Game - - if (title != other.title) return false - if (path != other.path) return false - if (programId != other.programId) return false - if (developer != other.developer) return false - if (version != other.version) return false - if (isHomebrew != other.isHomebrew) return false - - return true - } - - override fun hashCode(): Int { - var result = title.hashCode() - result = 31 * result + path.hashCode() - result = 31 * result + programId.hashCode() - result = 31 * result + developer.hashCode() - result = 31 * result + version.hashCode() - result = 31 * result + isHomebrew.hashCode() - return result - } - - companion object { - val extensions: Set = HashSet( - listOf("xci", "nsp", "nca", "nro") - ) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt deleted file mode 100644 index 274bc1c7b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameDir.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class GameDir( - val uriString: String, - var deepScan: Boolean -) : Parcelable diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt deleted file mode 100644 index 0135a95be..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameProperties.kt +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import kotlinx.coroutines.flow.StateFlow - -interface GameProperty { - @get:StringRes - val titleId: Int - - @get:StringRes - val descriptionId: Int - - @get:DrawableRes - val iconId: Int -} - -data class SubmenuProperty( - override val titleId: Int, - override val descriptionId: Int, - override val iconId: Int, - val details: (() -> String)? = null, - val detailsFlow: StateFlow? = null, - val action: () -> Unit -) : GameProperty - -data class InstallableProperty( - override val titleId: Int, - override val descriptionId: Int, - override val iconId: Int, - val install: (() -> Unit)? = null, - val export: (() -> Unit)? = null -) : GameProperty diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameVerificationResult.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameVerificationResult.kt deleted file mode 100644 index 804637fb8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GameVerificationResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -enum class GameVerificationResult(val int: Int) { - Success(0), - Failed(1), - NotImplemented(2); - - companion object { - fun from(int: Int): GameVerificationResult = - entries.firstOrNull { it.int == int } ?: Success - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt deleted file mode 100644 index 5ae05b5cc..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.preference.PreferenceManager -import java.util.Locale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.utils.GameHelper -import org.yuzu.yuzu_emu.utils.NativeConfig -import java.util.concurrent.atomic.AtomicBoolean - -class GamesViewModel : ViewModel() { - val games: StateFlow> get() = _games - private val _games = MutableStateFlow(emptyList()) - - val searchedGames: StateFlow> get() = _searchedGames - private val _searchedGames = MutableStateFlow(emptyList()) - - val isReloading: StateFlow get() = _isReloading - private val _isReloading = MutableStateFlow(false) - - private val reloading = AtomicBoolean(false) - - val shouldSwapData: StateFlow get() = _shouldSwapData - private val _shouldSwapData = MutableStateFlow(false) - - val shouldScrollToTop: StateFlow get() = _shouldScrollToTop - private val _shouldScrollToTop = MutableStateFlow(false) - - val searchFocused: StateFlow get() = _searchFocused - private val _searchFocused = MutableStateFlow(false) - - private val _folders = MutableStateFlow(mutableListOf()) - val folders = _folders.asStateFlow() - - init { - // Ensure keys are loaded so that ROM metadata can be decrypted. - NativeLibrary.reloadKeys() - - getGameDirs() - reloadGames(directoriesChanged = false, firstStartup = true) - } - - fun setGames(games: List) { - val sortedList = games.sortedWith( - compareBy( - { it.title.lowercase(Locale.getDefault()) }, - { it.path } - ) - ) - - _games.value = sortedList - } - - fun setSearchedGames(games: List) { - _searchedGames.value = games - } - - fun setShouldSwapData(shouldSwap: Boolean) { - _shouldSwapData.value = shouldSwap - } - - fun setShouldScrollToTop(shouldScroll: Boolean) { - _shouldScrollToTop.value = shouldScroll - } - - fun setSearchFocused(searchFocused: Boolean) { - _searchFocused.value = searchFocused - } - - fun reloadGames(directoriesChanged: Boolean, firstStartup: Boolean = false) { - if (reloading.get()) { - return - } - reloading.set(true) - _isReloading.value = true - - viewModelScope.launch { - withContext(Dispatchers.IO) { - if (firstStartup) { - // Retrieve list of cached games - val storedGames = - PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - .getStringSet(GameHelper.KEY_GAMES, emptySet()) - if (storedGames!!.isNotEmpty()) { - val deserializedGames = mutableSetOf() - storedGames.forEach { - val game: Game - try { - game = Json.decodeFromString(it) - } catch (e: Exception) { - // We don't care about any errors related to parsing the game cache - return@forEach - } - - val gameExists = - DocumentFile.fromSingleUri( - YuzuApplication.appContext, - Uri.parse(game.path) - )?.exists() - if (gameExists == true) { - deserializedGames.add(game) - } - } - setGames(deserializedGames.toList()) - } - } - - setGames(GameHelper.getGames()) - reloading.set(false) - _isReloading.value = false - - if (directoriesChanged) { - setShouldSwapData(true) - } - } - } - } - - fun addFolder(gameDir: GameDir) = - viewModelScope.launch { - withContext(Dispatchers.IO) { - NativeConfig.addGameDir(gameDir) - getGameDirs(true) - } - } - - fun removeFolder(gameDir: GameDir) = - viewModelScope.launch { - withContext(Dispatchers.IO) { - val gameDirs = _folders.value.toMutableList() - val removedDirIndex = gameDirs.indexOf(gameDir) - if (removedDirIndex != -1) { - gameDirs.removeAt(removedDirIndex) - NativeConfig.setGameDirs(gameDirs.toTypedArray()) - getGameDirs() - } - } - } - - fun updateGameDirs() = - viewModelScope.launch { - withContext(Dispatchers.IO) { - NativeConfig.setGameDirs(_folders.value.toTypedArray()) - getGameDirs() - } - } - - fun onOpenGameFoldersFragment() = - viewModelScope.launch { - withContext(Dispatchers.IO) { - getGameDirs() - } - } - - fun onCloseGameFoldersFragment() { - NativeConfig.saveGlobalConfig() - viewModelScope.launch { - withContext(Dispatchers.IO) { - getGameDirs(true) - } - } - } - - private fun getGameDirs(reloadList: Boolean = false) { - val gameDirs = NativeConfig.getGameDirs() - _folders.value = gameDirs.toMutableList() - if (reloadList) { - reloadGames(true) - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt deleted file mode 100644 index b32e19373..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_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/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt deleted file mode 100644 index cfc777b81..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.net.Uri -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -class HomeViewModel : ViewModel() { - val navigationVisible: StateFlow> get() = _navigationVisible - private val _navigationVisible = MutableStateFlow(Pair(false, false)) - - val statusBarShadeVisible: StateFlow get() = _statusBarShadeVisible - private val _statusBarShadeVisible = MutableStateFlow(true) - - val shouldPageForward: StateFlow get() = _shouldPageForward - private val _shouldPageForward = MutableStateFlow(false) - - private val _gamesDirSelected = MutableStateFlow(false) - val gamesDirSelected get() = _gamesDirSelected.asStateFlow() - - private val _openImportSaves = MutableStateFlow(false) - val openImportSaves get() = _openImportSaves.asStateFlow() - - private val _contentToInstall = MutableStateFlow?>(null) - val contentToInstall get() = _contentToInstall.asStateFlow() - - private val _reloadPropertiesList = MutableStateFlow(false) - val reloadPropertiesList get() = _reloadPropertiesList.asStateFlow() - - private val _checkKeys = MutableStateFlow(false) - val checkKeys = _checkKeys.asStateFlow() - - var navigatedToSetup = false - - fun setNavigationVisibility(visible: Boolean, animated: Boolean) { - if (navigationVisible.value.first == visible) { - return - } - _navigationVisible.value = Pair(visible, animated) - } - - fun setStatusBarShadeVisibility(visible: Boolean) { - if (statusBarShadeVisible.value == visible) { - return - } - _statusBarShadeVisible.value = visible - } - - fun setShouldPageForward(pageForward: Boolean) { - _shouldPageForward.value = pageForward - } - - fun setGamesDirSelected(selected: Boolean) { - _gamesDirSelected.value = selected - } - - fun setOpenImportSaves(import: Boolean) { - _openImportSaves.value = import - } - - fun setContentToInstall(documents: List?) { - _contentToInstall.value = documents - } - - fun reloadPropertiesList(reload: Boolean) { - _reloadPropertiesList.value = reload - } - - fun setCheckKeys(value: Boolean) { - _checkKeys.value = value - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt deleted file mode 100644 index 0c3cd0521..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/InstallResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -enum class InstallResult(val int: Int) { - Success(0), - Overwrite(1), - Failure(2), - BaseInstallAttempted(3); - - companion object { - fun from(int: Int): InstallResult = entries.firstOrNull { it.int == int } ?: Success - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt deleted file mode 100644 index 36a7c97b8..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Installable.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.annotation.StringRes - -data class Installable( - @StringRes val titleId: Int, - @StringRes val descriptionId: Int, - val install: (() -> Unit)? = null, - val export: (() -> Unit)? = null -) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt deleted file mode 100644 index f24d5cf34..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class License( - val titleId: Int, - val descriptionId: Int, - val linkId: Int, - val copyrightId: Int, - val licenseId: Int -) : Parcelable diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt deleted file mode 100644 index 2db005e49..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MessageDialogViewModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.lifecycle.ViewModel - -class MessageDialogViewModel : ViewModel() { - var positiveAction: (() -> Unit)? = null - var negativeAction: (() -> Unit)? = null - - fun clear() { - positiveAction = null - negativeAction = null - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt deleted file mode 100644 index b4b78e42d..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import android.net.Uri -import android.provider.DocumentsContract - -class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) { - val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt deleted file mode 100644 index 25cb9e365..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Patch.kt +++ /dev/null @@ -1,16 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -import androidx.annotation.Keep - -@Keep -data class Patch( - var enabled: Boolean, - val name: String, - val version: String, - val type: Int, - val programId: String, - val titleId: String -) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt deleted file mode 100644 index e9a54162b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/PatchType.kt +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -enum class PatchType(val int: Int) { - Update(0), - DLC(1), - Mod(2); - - companion object { - fun from(int: Int): PatchType = entries.firstOrNull { it.int == int } ?: Update - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt deleted file mode 100644 index 11c22d323..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SelectableItem.kt +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -interface SelectableItem { - var selected: Boolean - fun onSelectionStateChanged(selected: Boolean) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt deleted file mode 100644 index 09a128ae6..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -data class SetupPage( - val iconId: Int, - val titleId: Int, - val descriptionId: Int, - val buttonIconId: Int, - val leftAlignedIcon: Boolean, - val buttonTextId: Int, - val buttonAction: (callback: SetupCallback) -> Unit, - val hasWarning: Boolean, - val warningTitleId: Int = 0, - val warningDescriptionId: Int = 0, - val warningHelpLinkId: Int = 0, - val stepCompleted: () -> StepState = { StepState.UNDEFINED } -) - -interface SetupCallback { - fun onStepCompleted() -} - -enum class StepState { - COMPLETE, - INCOMPLETE, - UNDEFINED -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt deleted file mode 100644 index 4361eb972..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.model - -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 - -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) - - private val _progress = MutableStateFlow(0.0) - val progress = _progress.asStateFlow() - - private val _maxProgress = MutableStateFlow(0.0) - val maxProgress = _maxProgress.asStateFlow() - - private val _message = MutableStateFlow("") - val message = _message.asStateFlow() - - lateinit var task: suspend ( - progressCallback: (max: Long, progress: Long) -> Boolean, - messageCallback: (message: String) -> Unit - ) -> Any - - fun clear() { - _result.value = Any() - _isComplete.value = false - _isRunning.value = false - _cancelled.value = false - _progress.value = 0.0 - _maxProgress.value = 0.0 - _message.value = "" - } - - fun setCancelled(value: Boolean) { - _cancelled.value = value - } - - fun runTask() { - if (isRunning.value) { - return - } - _isRunning.value = true - - viewModelScope.launch(Dispatchers.IO) { - val res = task( - { max, progress -> - _maxProgress.value = max.toDouble() - _progress.value = progress.toDouble() - return@task cancelled.value - }, - { message -> - _message.value = message - } - ) - _result.value = res - _isComplete.value = true - _isRunning.value = false - } - } -} - -enum class TaskState { - Completed, - Failed, - Cancelled -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt deleted file mode 100644 index 737e03584..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt +++ /dev/null @@ -1,1049 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay - -import android.app.Activity -import android.content.Context -import android.content.SharedPreferences -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Point -import android.graphics.Rect -import android.graphics.drawable.Drawable -import android.graphics.drawable.VectorDrawable -import android.os.Build -import android.util.AttributeSet -import android.view.HapticFeedbackConstants -import android.view.MotionEvent -import android.view.SurfaceView -import android.view.View -import android.view.View.OnTouchListener -import android.view.WindowInsets -import androidx.core.content.ContextCompat -import androidx.window.layout.WindowMetricsCalculator -import kotlin.math.max -import kotlin.math.min -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.features.input.model.NpadStyleIndex -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.overlay.model.OverlayControl -import org.yuzu.yuzu_emu.overlay.model.OverlayControlData -import org.yuzu.yuzu_emu.overlay.model.OverlayLayout -import org.yuzu.yuzu_emu.utils.NativeConfig - -/** - * Draws the interactive input overlay on top of the - * [SurfaceView] that is rendering emulation. - */ -class InputOverlay(context: Context, attrs: AttributeSet?) : - SurfaceView(context, attrs), - OnTouchListener { - private val overlayButtons: MutableSet = HashSet() - private val overlayDpads: MutableSet = HashSet() - private val overlayJoysticks: MutableSet = HashSet() - - private var inEditMode = false - private var buttonBeingConfigured: InputOverlayDrawableButton? = null - private var dpadBeingConfigured: InputOverlayDrawableDpad? = null - private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null - - private lateinit var windowInsets: WindowInsets - - var layout = OverlayLayout.Landscape - - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - - windowInsets = rootWindowInsets - - val overlayControlData = NativeConfig.getOverlayControlData() - if (overlayControlData.isEmpty()) { - populateDefaultConfig() - } else { - checkForNewControls(overlayControlData) - } - - // Load the controls. - refreshControls() - - // Set the on touch listener. - setOnTouchListener(this) - - // Force draw - setWillNotDraw(false) - - // Request focus for the overlay so it has priority on presses. - requestFocus() - } - - override fun draw(canvas: Canvas) { - super.draw(canvas) - for (button in overlayButtons) { - button.draw(canvas) - } - for (dpad in overlayDpads) { - dpad.draw(canvas) - } - for (joystick in overlayJoysticks) { - joystick.draw(canvas) - } - } - - override fun onTouch(v: View, event: MotionEvent): Boolean { - if (inEditMode) { - return onTouchWhileEditing(event) - } - - var shouldUpdateView = false - val playerIndex = when (NativeInput.getStyleIndex(0)) { - NpadStyleIndex.Handheld -> 8 - else -> 0 - } - - for (button in overlayButtons) { - if (!button.updateStatus(event)) { - continue - } - NativeInput.onOverlayButtonEvent( - playerIndex, - button.button, - button.status - ) - playHaptics(event) - shouldUpdateView = true - } - - for (dpad in overlayDpads) { - if (!dpad.updateStatus(event, BooleanSetting.DPAD_SLIDE.getBoolean())) { - continue - } - NativeInput.onOverlayButtonEvent( - playerIndex, - dpad.up, - dpad.upStatus - ) - NativeInput.onOverlayButtonEvent( - playerIndex, - dpad.down, - dpad.downStatus - ) - NativeInput.onOverlayButtonEvent( - playerIndex, - dpad.left, - dpad.leftStatus - ) - NativeInput.onOverlayButtonEvent( - playerIndex, - dpad.right, - dpad.rightStatus - ) - playHaptics(event) - shouldUpdateView = true - } - - for (joystick in overlayJoysticks) { - if (!joystick.updateStatus(event)) { - continue - } - NativeInput.onOverlayJoystickEvent( - playerIndex, - joystick.joystick, - joystick.xAxis, - joystick.realYAxis - ) - NativeInput.onOverlayButtonEvent( - playerIndex, - joystick.button, - joystick.buttonStatus - ) - playHaptics(event) - shouldUpdateView = true - } - - if (shouldUpdateView) { - invalidate() - } - - if (!BooleanSetting.TOUCHSCREEN.getBoolean()) { - return true - } - - val pointerIndex = event.actionIndex - val xPosition = event.getX(pointerIndex).toInt() - val yPosition = event.getY(pointerIndex).toInt() - val pointerId = event.getPointerId(pointerIndex) - val motionEvent = event.action and MotionEvent.ACTION_MASK - val isActionDown = - motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN - val isActionMove = motionEvent == MotionEvent.ACTION_MOVE - val isActionUp = - motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP - - if (isActionDown && !isTouchInputConsumed(pointerId)) { - NativeInput.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat()) - } - - if (isActionMove) { - for (i in 0 until event.pointerCount) { - val fingerId = event.getPointerId(i) - if (isTouchInputConsumed(fingerId)) { - continue - } - NativeInput.onTouchMoved(fingerId, event.getX(i), event.getY(i)) - } - } - - if (isActionUp && !isTouchInputConsumed(pointerId)) { - NativeInput.onTouchReleased(pointerId) - } - - return true - } - - private fun playHaptics(event: MotionEvent) { - if (BooleanSetting.HAPTIC_FEEDBACK.getBoolean()) { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN -> - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP -> - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE) - } - } - } - - private fun isTouchInputConsumed(track_id: Int): Boolean { - for (button in overlayButtons) { - if (button.trackId == track_id) { - return true - } - } - for (dpad in overlayDpads) { - if (dpad.trackId == track_id) { - return true - } - } - for (joystick in overlayJoysticks) { - if (joystick.trackId == track_id) { - return true - } - } - return false - } - - private fun onTouchWhileEditing(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val fingerPositionX = event.getX(pointerIndex).toInt() - val fingerPositionY = event.getY(pointerIndex).toInt() - - for (button in overlayButtons) { - // Determine the button state to apply based on the MotionEvent action flag. - when (event.action and MotionEvent.ACTION_MASK) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN -> - // If no button is being moved now, remember the currently touched button to move. - if (buttonBeingConfigured == null && - button.bounds.contains(fingerPositionX, fingerPositionY) - ) { - buttonBeingConfigured = button - buttonBeingConfigured!!.onConfigureTouch(event) - } - - MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) { - buttonBeingConfigured!!.onConfigureTouch(event) - invalidate() - return true - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) { - // Persist button position by saving new place. - saveControlPosition( - buttonBeingConfigured!!.overlayControlData.id, - buttonBeingConfigured!!.bounds.centerX(), - buttonBeingConfigured!!.bounds.centerY(), - layout - ) - buttonBeingConfigured = null - } - } - } - - for (dpad in overlayDpads) { - // Determine the button state to apply based on the MotionEvent action flag. - when (event.action and MotionEvent.ACTION_MASK) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN -> - // If no button is being moved now, remember the currently touched button to move. - if (buttonBeingConfigured == null && - dpad.bounds.contains(fingerPositionX, fingerPositionY) - ) { - dpadBeingConfigured = dpad - dpadBeingConfigured!!.onConfigureTouch(event) - } - - MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) { - dpadBeingConfigured!!.onConfigureTouch(event) - invalidate() - return true - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) { - // Persist button position by saving new place. - saveControlPosition( - OverlayControl.COMBINED_DPAD.id, - dpadBeingConfigured!!.bounds.centerX(), - dpadBeingConfigured!!.bounds.centerY(), - layout - ) - dpadBeingConfigured = null - } - } - } - - for (joystick in overlayJoysticks) { - when (event.action) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null && - joystick.bounds.contains(fingerPositionX, fingerPositionY) - ) { - joystickBeingConfigured = joystick - joystickBeingConfigured!!.onConfigureTouch(event) - } - - MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) { - joystickBeingConfigured!!.onConfigureTouch(event) - invalidate() - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) { - saveControlPosition( - joystickBeingConfigured!!.prefId, - joystickBeingConfigured!!.bounds.centerX(), - joystickBeingConfigured!!.bounds.centerY(), - layout - ) - joystickBeingConfigured = null - } - } - } - - return true - } - - private fun addOverlayControls(layout: OverlayLayout) { - val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) - val overlayControlData = NativeConfig.getOverlayControlData() - for (data in overlayControlData) { - if (!data.enabled) { - continue - } - - val position = data.positionFromLayout(layout) - when (data.id) { - OverlayControl.BUTTON_A.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_a, - R.drawable.facebutton_a_depressed, - NativeButton.A, - data, - position - ) - ) - } - - OverlayControl.BUTTON_B.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_b, - R.drawable.facebutton_b_depressed, - NativeButton.B, - data, - position - ) - ) - } - - OverlayControl.BUTTON_X.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_x, - R.drawable.facebutton_x_depressed, - NativeButton.X, - data, - position - ) - ) - } - - OverlayControl.BUTTON_Y.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_y, - R.drawable.facebutton_y_depressed, - NativeButton.Y, - data, - position - ) - ) - } - - OverlayControl.BUTTON_PLUS.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_plus, - R.drawable.facebutton_plus_depressed, - NativeButton.Plus, - data, - position - ) - ) - } - - OverlayControl.BUTTON_MINUS.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_minus, - R.drawable.facebutton_minus_depressed, - NativeButton.Minus, - data, - position - ) - ) - } - - OverlayControl.BUTTON_HOME.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_home, - R.drawable.facebutton_home_depressed, - NativeButton.Home, - data, - position - ) - ) - } - - OverlayControl.BUTTON_CAPTURE.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.facebutton_screenshot, - R.drawable.facebutton_screenshot_depressed, - NativeButton.Capture, - data, - position - ) - ) - } - - OverlayControl.BUTTON_L.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.l_shoulder, - R.drawable.l_shoulder_depressed, - NativeButton.L, - data, - position - ) - ) - } - - OverlayControl.BUTTON_R.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.r_shoulder, - R.drawable.r_shoulder_depressed, - NativeButton.R, - data, - position - ) - ) - } - - OverlayControl.BUTTON_ZL.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.zl_trigger, - R.drawable.zl_trigger_depressed, - NativeButton.ZL, - data, - position - ) - ) - } - - OverlayControl.BUTTON_ZR.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.zr_trigger, - R.drawable.zr_trigger_depressed, - NativeButton.ZR, - data, - position - ) - ) - } - - OverlayControl.BUTTON_STICK_L.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.button_l3, - R.drawable.button_l3_depressed, - NativeButton.LStick, - data, - position - ) - ) - } - - OverlayControl.BUTTON_STICK_R.id -> { - overlayButtons.add( - initializeOverlayButton( - context, - windowSize, - R.drawable.button_r3, - R.drawable.button_r3_depressed, - NativeButton.RStick, - data, - position - ) - ) - } - - OverlayControl.STICK_L.id -> { - overlayJoysticks.add( - initializeOverlayJoystick( - context, - windowSize, - R.drawable.joystick_range, - R.drawable.joystick, - R.drawable.joystick_depressed, - NativeAnalog.LStick, - NativeButton.LStick, - data, - position - ) - ) - } - - OverlayControl.STICK_R.id -> { - overlayJoysticks.add( - initializeOverlayJoystick( - context, - windowSize, - R.drawable.joystick_range, - R.drawable.joystick, - R.drawable.joystick_depressed, - NativeAnalog.RStick, - NativeButton.RStick, - data, - position - ) - ) - } - - OverlayControl.COMBINED_DPAD.id -> { - overlayDpads.add( - initializeOverlayDpad( - context, - windowSize, - R.drawable.dpad_standard, - R.drawable.dpad_standard_cardinal_depressed, - R.drawable.dpad_standard_diagonal_depressed, - position - ) - ) - } - } - } - } - - fun refreshControls() { - // Remove all the overlay buttons from the HashSet. - overlayButtons.clear() - overlayDpads.clear() - overlayJoysticks.clear() - - // Add all the enabled overlay items back to the HashSet. - if (BooleanSetting.SHOW_INPUT_OVERLAY.getBoolean()) { - addOverlayControls(layout) - } - invalidate() - } - - private fun saveControlPosition(id: String, x: Int, y: Int, layout: OverlayLayout) { - val windowSize = getSafeScreenSize(context, Pair(measuredWidth, measuredHeight)) - val min = windowSize.first - val max = windowSize.second - val overlayControlData = NativeConfig.getOverlayControlData() - val data = overlayControlData.firstOrNull { it.id == id } - val newPosition = Pair((x - min.x).toDouble() / max.x, (y - min.y).toDouble() / max.y) - when (layout) { - OverlayLayout.Landscape -> data?.landscapePosition = newPosition - OverlayLayout.Portrait -> data?.portraitPosition = newPosition - OverlayLayout.Foldable -> data?.foldablePosition = newPosition - } - NativeConfig.setOverlayControlData(overlayControlData) - } - - fun setIsInEditMode(editMode: Boolean) { - inEditMode = editMode - } - - /** - * Applies and saves all default values for the overlay - */ - private fun populateDefaultConfig() { - val newConfig = OverlayControl.entries.map { it.toOverlayControlData() } - NativeConfig.setOverlayControlData(newConfig.toTypedArray()) - NativeConfig.saveGlobalConfig() - } - - /** - * Checks if any new controls were added to OverlayControl that do not exist within deserialized - * config and adds / saves them if necessary - * - * @param overlayControlData Overlay control data from [NativeConfig.getOverlayControlData] - */ - private fun checkForNewControls(overlayControlData: Array) { - val missingControls = mutableListOf() - OverlayControl.entries.forEach { defaultControl -> - val controlData = overlayControlData.firstOrNull { it.id == defaultControl.id } - if (controlData == null) { - missingControls.add(defaultControl.toOverlayControlData()) - } - } - - if (missingControls.isNotEmpty()) { - NativeConfig.setOverlayControlData( - arrayOf(*overlayControlData, *(missingControls.toTypedArray())) - ) - NativeConfig.saveGlobalConfig() - } - } - - fun resetLayoutVisibilityAndPlacement() { - defaultOverlayPositionByLayout(layout) - - val overlayControlData = NativeConfig.getOverlayControlData() - overlayControlData.forEach { - it.enabled = OverlayControl.from(it.id)?.defaultVisibility == true - } - NativeConfig.setOverlayControlData(overlayControlData) - - refreshControls() - } - - private fun defaultOverlayPositionByLayout(layout: OverlayLayout) { - val overlayControlData = NativeConfig.getOverlayControlData() - for (data in overlayControlData) { - val defaultControlData = OverlayControl.from(data.id) ?: continue - val position = defaultControlData.getDefaultPositionForLayout(layout) - when (layout) { - OverlayLayout.Landscape -> data.landscapePosition = position - OverlayLayout.Portrait -> data.portraitPosition = position - OverlayLayout.Foldable -> data.foldablePosition = position - } - } - NativeConfig.setOverlayControlData(overlayControlData) - } - - override fun isInEditMode(): Boolean { - return inEditMode - } - - companion object { - // Increase this number every time there is a breaking change to every overlay layout - const val OVERLAY_VERSION = 1 - - // Increase the corresponding layout version number whenever that layout has a breaking change - private const val LANDSCAPE_OVERLAY_VERSION = 1 - private const val PORTRAIT_OVERLAY_VERSION = 1 - private const val FOLDABLE_OVERLAY_VERSION = 1 - val overlayLayoutVersions = listOf( - LANDSCAPE_OVERLAY_VERSION, - PORTRAIT_OVERLAY_VERSION, - FOLDABLE_OVERLAY_VERSION - ) - - /** - * Resizes a [Bitmap] by a given scale factor - * - * @param context Context for getting the vector drawable - * @param drawableId The ID of the drawable to scale. - * @param scale The scale factor for the bitmap. - * @return The scaled [Bitmap] - */ - private fun getBitmap(context: Context, drawableId: Int, scale: Float): Bitmap { - val vectorDrawable = ContextCompat.getDrawable(context, drawableId) as VectorDrawable - - val bitmap = Bitmap.createBitmap( - (vectorDrawable.intrinsicWidth * scale).toInt(), - (vectorDrawable.intrinsicHeight * scale).toInt(), - Bitmap.Config.ARGB_8888 - ) - - val dm = context.resources.displayMetrics - val minScreenDimension = min(dm.widthPixels, dm.heightPixels) - - val maxBitmapDimension = max(bitmap.width, bitmap.height) - val bitmapScale = scale * minScreenDimension / maxBitmapDimension - - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - (bitmap.width * bitmapScale).toInt(), - (bitmap.height * bitmapScale).toInt(), - true - ) - - val canvas = Canvas(scaledBitmap) - vectorDrawable.setBounds(0, 0, canvas.width, canvas.height) - vectorDrawable.draw(canvas) - return scaledBitmap - } - - /** - * Gets the safe screen size for drawing the overlay - * - * @param context Context for getting the window metrics - * @return A pair of points, the first being the top left corner of the safe area, - * the second being the bottom right corner of the safe area - */ - private fun getSafeScreenSize( - context: Context, - screenSize: Pair - ): Pair { - // Get screen size - val windowMetrics = WindowMetricsCalculator.getOrCreate() - .computeCurrentWindowMetrics(context as Activity) - var maxX = screenSize.first.toFloat() - var maxY = screenSize.second.toFloat() - var minX = 0 - var minY = 0 - - // If we have API access, calculate the safe area to draw the overlay - var cutoutLeft = 0 - var cutoutBottom = 0 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val insets = context.windowManager.currentWindowMetrics.windowInsets.displayCutout - if (insets != null) { - if (insets.boundingRectTop.bottom != 0 && - insets.boundingRectTop.bottom > maxY / 2 - ) { - maxY = insets.boundingRectTop.bottom.toFloat() - } - if (insets.boundingRectRight.left != 0 && - insets.boundingRectRight.left > maxX / 2 - ) { - maxX = insets.boundingRectRight.left.toFloat() - } - - minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left - minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom - - cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left - cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom - } - } - - // This makes sure that if we have an inset on one side of the screen, we mirror it on - // the other side. Since removing space from one of the max values messes with the scale, - // we also have to account for it using our min values. - if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft - if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom - if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) { - maxX -= (minX * 2) - } else if (minX > 0) { - maxX -= minX - } - if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) { - maxY -= (minY * 2) - } else if (minY > 0) { - maxY -= minY - } - - return Pair(Point(minX, minY), Point(maxX.toInt(), maxY.toInt())) - } - - /** - * Initializes an InputOverlayDrawableButton, given by resId, with all of the - * parameters set for it to be properly shown on the InputOverlay. - * - * - * This works due to the way the X and Y coordinates are stored within - * the [SharedPreferences]. - * - * - * In the input overlay configuration menu, - * once a touch event begins and then ends (ie. Organizing the buttons to one's own liking for the overlay). - * the X and Y coordinates of the button at the END of its touch event - * (when you remove your finger/stylus from the touchscreen) are then stored in a native . - * - * Technically no modifications should need to be performed on the returned - * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait - * for Android to call the onDraw method. - * - * @param context The current [Context]. - * @param windowSize The size of the window to draw the overlay on. - * @param defaultResId The resource ID of the [Drawable] to get the [Bitmap] of (Default State). - * @param pressedResId The resource ID of the [Drawable] to get the [Bitmap] of (Pressed State). - * @param buttonId Identifier for determining what type of button the initialized InputOverlayDrawableButton represents. - * @param overlayControlData Identifier for determining where a button appears on screen. - * @param position The position on screen as represented by an x and y value between 0 and 1. - * @return An [InputOverlayDrawableButton] with the correct drawing bounds set. - */ - private fun initializeOverlayButton( - context: Context, - windowSize: Pair, - defaultResId: Int, - pressedResId: Int, - button: NativeButton, - overlayControlData: OverlayControlData, - position: Pair - ): InputOverlayDrawableButton { - // Resources handle for fetching the initial Drawable resource. - val res = context.resources - - // Decide scale based on button preference ID and user preference - var scale: Float = when (overlayControlData.id) { - OverlayControl.BUTTON_HOME.id, - OverlayControl.BUTTON_CAPTURE.id, - OverlayControl.BUTTON_PLUS.id, - OverlayControl.BUTTON_MINUS.id -> 0.07f - - OverlayControl.BUTTON_L.id, - OverlayControl.BUTTON_R.id, - OverlayControl.BUTTON_ZL.id, - OverlayControl.BUTTON_ZR.id -> 0.26f - - OverlayControl.BUTTON_STICK_L.id, - OverlayControl.BUTTON_STICK_R.id -> 0.155f - - else -> 0.11f - } - scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() - scale /= 100f - - // Initialize the InputOverlayDrawableButton. - val defaultStateBitmap = getBitmap(context, defaultResId, scale) - val pressedStateBitmap = getBitmap(context, pressedResId, scale) - val overlayDrawable = InputOverlayDrawableButton( - res, - defaultStateBitmap, - pressedStateBitmap, - button, - overlayControlData - ) - - // Get the minimum and maximum coordinates of the screen where the button can be placed. - val min = windowSize.first - val max = windowSize.second - - // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. - // These were set in the input overlay configuration menu. - val drawableX = (position.first * max.x + min.x).toInt() - val drawableY = (position.second * max.y + min.y).toInt() - val width = overlayDrawable.width - val height = overlayDrawable.height - - // Now set the bounds for the InputOverlayDrawableButton. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableButton will be. - overlayDrawable.setBounds( - drawableX - (width / 2), - drawableY - (height / 2), - drawableX + (width / 2), - drawableY + (height / 2) - ) - - // Need to set the image's position - overlayDrawable.setPosition( - drawableX - (width / 2), - drawableY - (height / 2) - ) - overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) - return overlayDrawable - } - - /** - * Initializes an [InputOverlayDrawableDpad] - * - * @param context The current [Context]. - * @param windowSize The size of the window to draw the overlay on. - * @param defaultResId The [Bitmap] resource ID of the default state. - * @param pressedOneDirectionResId The [Bitmap] resource ID of the pressed state in one direction. - * @param pressedTwoDirectionsResId The [Bitmap] resource ID of the pressed state in two directions. - * @param position The position on screen as represented by an x and y value between 0 and 1. - * @return The initialized [InputOverlayDrawableDpad] - */ - private fun initializeOverlayDpad( - context: Context, - windowSize: Pair, - defaultResId: Int, - pressedOneDirectionResId: Int, - pressedTwoDirectionsResId: Int, - position: Pair - ): InputOverlayDrawableDpad { - // Resources handle for fetching the initial Drawable resource. - val res = context.resources - - // Decide scale based on button ID and user preference - var scale = 0.25f - scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() - scale /= 100f - - // Initialize the InputOverlayDrawableDpad. - val defaultStateBitmap = - getBitmap(context, defaultResId, scale) - val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale) - val pressedTwoDirectionsStateBitmap = - getBitmap(context, pressedTwoDirectionsResId, scale) - - val overlayDrawable = InputOverlayDrawableDpad( - res, - defaultStateBitmap, - pressedOneDirectionStateBitmap, - pressedTwoDirectionsStateBitmap - ) - - // Get the minimum and maximum coordinates of the screen where the button can be placed. - val min = windowSize.first - val max = windowSize.second - - // The X and Y coordinates of the InputOverlayDrawableDpad on the InputOverlay. - // These were set in the input overlay configuration menu. - val drawableX = (position.first * max.x + min.x).toInt() - val drawableY = (position.second * max.y + min.y).toInt() - val width = overlayDrawable.width - val height = overlayDrawable.height - - // Now set the bounds for the InputOverlayDrawableDpad. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableDpad will be. - overlayDrawable.setBounds( - drawableX - (width / 2), - drawableY - (height / 2), - drawableX + (width / 2), - drawableY + (height / 2) - ) - - // Need to set the image's position - overlayDrawable.setPosition(drawableX - (width / 2), drawableY - (height / 2)) - overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) - return overlayDrawable - } - - /** - * Initializes an [InputOverlayDrawableJoystick] - * - * @param context The current [Context] - * @param windowSize The size of the window to draw the overlay on. - * @param resOuter Resource ID for the outer image of the joystick (the static image that shows the circular bounds). - * @param defaultResInner Resource ID for the default inner image of the joystick (the one you actually move around). - * @param pressedResInner Resource ID for the pressed inner image of the joystick. - * @param joystick Identifier for which joystick this is. - * @param buttonId Identifier for which joystick button this is. - * @param overlayControlData Identifier for determining where a button appears on screen. - * @param position The position on screen as represented by an x and y value between 0 and 1. - * @return The initialized [InputOverlayDrawableJoystick]. - */ - private fun initializeOverlayJoystick( - context: Context, - windowSize: Pair, - resOuter: Int, - defaultResInner: Int, - pressedResInner: Int, - joystick: NativeAnalog, - button: NativeButton, - overlayControlData: OverlayControlData, - position: Pair - ): InputOverlayDrawableJoystick { - // Resources handle for fetching the initial Drawable resource. - val res = context.resources - - // Decide scale based on user preference - var scale = 0.3f - scale *= (IntSetting.OVERLAY_SCALE.getInt() + 50).toFloat() - scale /= 100f - - // Initialize the InputOverlayDrawableJoystick. - val bitmapOuter = getBitmap(context, resOuter, scale) - val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f) - val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f) - - // Get the minimum and maximum coordinates of the screen where the button can be placed. - val min = windowSize.first - val max = windowSize.second - - // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay. - // These were set in the input overlay configuration menu. - val drawableX = (position.first * max.x + min.x).toInt() - val drawableY = (position.second * max.y + min.y).toInt() - val outerScale = 1.66f - - // Now set the bounds for the InputOverlayDrawableJoystick. - // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be. - val outerSize = bitmapOuter.width - val outerRect = Rect( - drawableX - (outerSize / 2), - drawableY - (outerSize / 2), - drawableX + (outerSize / 2), - drawableY + (outerSize / 2) - ) - val innerRect = - Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt()) - - // Send the drawableId to the joystick so it can be referenced when saving control position. - val overlayDrawable = InputOverlayDrawableJoystick( - res, - bitmapOuter, - bitmapInnerDefault, - bitmapInnerPressed, - outerRect, - innerRect, - joystick, - button, - overlayControlData.id - ) - - // Need to set the image's position - overlayDrawable.setPosition(drawableX, drawableY) - overlayDrawable.setOpacity(IntSetting.OVERLAY_OPACITY.getInt() * 255 / 100) - return overlayDrawable - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt deleted file mode 100644 index fee3d04ee..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt +++ /dev/null @@ -1,151 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay - -import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Rect -import android.graphics.drawable.BitmapDrawable -import android.view.MotionEvent -import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.overlay.model.OverlayControlData - -/** - * Custom [BitmapDrawable] that is capable - * of storing it's own ID. - * - * @param res [Resources] instance. - * @param defaultStateBitmap [Bitmap] to use with the default state Drawable. - * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable. - * @param button [NativeButton] for this type of button. - */ -class InputOverlayDrawableButton( - res: Resources, - defaultStateBitmap: Bitmap, - pressedStateBitmap: Bitmap, - val button: NativeButton, - val overlayControlData: OverlayControlData -) { - // The ID value what motion event is tracking - var trackId: Int - - // The drawable position on the screen - private var buttonPositionX = 0 - private var buttonPositionY = 0 - - val width: Int - val height: Int - - private val defaultStateBitmap: BitmapDrawable - private val pressedStateBitmap: BitmapDrawable - private var pressedState = false - - private var previousTouchX = 0 - private var previousTouchY = 0 - var controlPositionX = 0 - var controlPositionY = 0 - - init { - this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) - this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap) - trackId = -1 - width = this.defaultStateBitmap.intrinsicWidth - height = this.defaultStateBitmap.intrinsicHeight - } - - /** - * Updates button status based on the motion event. - * - * @return true if value was changed - */ - fun updateStatus(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val xPosition = event.getX(pointerIndex).toInt() - val yPosition = event.getY(pointerIndex).toInt() - val pointerId = event.getPointerId(pointerIndex) - val motionEvent = event.action and MotionEvent.ACTION_MASK - val isActionDown = - motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN - val isActionUp = - motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP - - if (isActionDown) { - if (!bounds.contains(xPosition, yPosition)) { - return false - } - pressedState = true - trackId = pointerId - return true - } - - if (isActionUp) { - if (trackId != pointerId) { - return false - } - pressedState = false - trackId = -1 - return true - } - - return false - } - - fun setPosition(x: Int, y: Int) { - buttonPositionX = x - buttonPositionY = y - } - - fun draw(canvas: Canvas?) { - currentStateBitmapDrawable.draw(canvas!!) - } - - private val currentStateBitmapDrawable: BitmapDrawable - get() = if (pressedState) pressedStateBitmap else defaultStateBitmap - - fun onConfigureTouch(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val fingerPositionX = event.getX(pointerIndex).toInt() - val fingerPositionY = event.getY(pointerIndex).toInt() - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - controlPositionX = fingerPositionX - (width / 2) - controlPositionY = fingerPositionY - (height / 2) - } - - MotionEvent.ACTION_MOVE -> { - controlPositionX += fingerPositionX - previousTouchX - controlPositionY += fingerPositionY - previousTouchY - setBounds( - controlPositionX, - controlPositionY, - width + controlPositionX, - height + controlPositionY - ) - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - } - } - return true - } - - fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { - defaultStateBitmap.setBounds(left, top, right, bottom) - pressedStateBitmap.setBounds(left, top, right, bottom) - } - - fun setOpacity(value: Int) { - defaultStateBitmap.alpha = value - pressedStateBitmap.alpha = value - } - - val status: Int - get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED - val bounds: Rect - get() = defaultStateBitmap.bounds -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt deleted file mode 100644 index 0cb6ff244..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt +++ /dev/null @@ -1,266 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay - -import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Rect -import android.graphics.drawable.BitmapDrawable -import android.view.MotionEvent -import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState -import org.yuzu.yuzu_emu.features.input.model.NativeButton - -/** - * Custom [BitmapDrawable] that is capable - * of storing it's own ID. - * - * @param res [Resources] instance. - * @param defaultStateBitmap [Bitmap] of the default state. - * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction. - * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction. - */ -class InputOverlayDrawableDpad( - res: Resources, - defaultStateBitmap: Bitmap, - pressedOneDirectionStateBitmap: Bitmap, - pressedTwoDirectionsStateBitmap: Bitmap -) { - /** - * Gets one of the InputOverlayDrawableDpad's button IDs. - * - * @return the requested InputOverlayDrawableDpad's button ID. - */ - // The ID identifying what type of button this Drawable represents. - val up = NativeButton.DUp - val down = NativeButton.DDown - val left = NativeButton.DLeft - val right = NativeButton.DRight - var trackId: Int - - val width: Int - val height: Int - - private val defaultStateBitmap: BitmapDrawable - private val pressedOneDirectionStateBitmap: BitmapDrawable - private val pressedTwoDirectionsStateBitmap: BitmapDrawable - - private var previousTouchX = 0 - private var previousTouchY = 0 - private var controlPositionX = 0 - private var controlPositionY = 0 - - private var upButtonState = false - private var downButtonState = false - private var leftButtonState = false - private var rightButtonState = false - - init { - this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap) - this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap) - this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap) - width = this.defaultStateBitmap.intrinsicWidth - height = this.defaultStateBitmap.intrinsicHeight - trackId = -1 - } - - fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean { - val pointerIndex = event.actionIndex - val xPosition = event.getX(pointerIndex).toInt() - val yPosition = event.getY(pointerIndex).toInt() - val pointerId = event.getPointerId(pointerIndex) - val motionEvent = event.action and MotionEvent.ACTION_MASK - val isActionDown = - motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN - val isActionUp = - motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP - if (isActionDown) { - if (!bounds.contains(xPosition, yPosition)) { - return false - } - trackId = pointerId - } - if (isActionUp) { - if (trackId != pointerId) { - return false - } - trackId = -1 - upButtonState = false - downButtonState = false - leftButtonState = false - rightButtonState = false - return true - } - if (trackId == -1) { - return false - } - if (!dpad_slide && !isActionDown) { - return false - } - for (i in 0 until event.pointerCount) { - if (trackId != event.getPointerId(i)) { - continue - } - - var touchX = event.getX(i) - var touchY = event.getY(i) - var maxY = bounds.bottom.toFloat() - var maxX = bounds.right.toFloat() - touchX -= bounds.centerX().toFloat() - maxX -= bounds.centerX().toFloat() - touchY -= bounds.centerY().toFloat() - maxY -= bounds.centerY().toFloat() - val axisX = touchX / maxX - val axisY = touchY / maxY - val oldUpState = upButtonState - val oldDownState = downButtonState - val oldLeftState = leftButtonState - val oldRightState = rightButtonState - - upButtonState = axisY < -VIRT_AXIS_DEADZONE - downButtonState = axisY > VIRT_AXIS_DEADZONE - leftButtonState = axisX < -VIRT_AXIS_DEADZONE - rightButtonState = axisX > VIRT_AXIS_DEADZONE - return oldUpState != upButtonState || - oldDownState != downButtonState || - oldLeftState != leftButtonState || - oldRightState != rightButtonState - } - return false - } - - fun draw(canvas: Canvas) { - val px = controlPositionX + width / 2 - val py = controlPositionY + height / 2 - - // Pressed up - if (upButtonState && !leftButtonState && !rightButtonState) { - pressedOneDirectionStateBitmap.draw(canvas) - return - } - - // Pressed down - if (downButtonState && !leftButtonState && !rightButtonState) { - canvas.save() - canvas.rotate(180f, px.toFloat(), py.toFloat()) - pressedOneDirectionStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Pressed left - if (leftButtonState && !upButtonState && !downButtonState) { - canvas.save() - canvas.rotate(270f, px.toFloat(), py.toFloat()) - pressedOneDirectionStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Pressed right - if (rightButtonState && !upButtonState && !downButtonState) { - canvas.save() - canvas.rotate(90f, px.toFloat(), py.toFloat()) - pressedOneDirectionStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Pressed up left - if (upButtonState && leftButtonState && !rightButtonState) { - pressedTwoDirectionsStateBitmap.draw(canvas) - return - } - - // Pressed up right - if (upButtonState && !leftButtonState && rightButtonState) { - canvas.save() - canvas.rotate(90f, px.toFloat(), py.toFloat()) - pressedTwoDirectionsStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Pressed down right - if (downButtonState && !leftButtonState && rightButtonState) { - canvas.save() - canvas.rotate(180f, px.toFloat(), py.toFloat()) - pressedTwoDirectionsStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Pressed down left - if (downButtonState && leftButtonState && !rightButtonState) { - canvas.save() - canvas.rotate(270f, px.toFloat(), py.toFloat()) - pressedTwoDirectionsStateBitmap.draw(canvas) - canvas.restore() - return - } - - // Not pressed - defaultStateBitmap.draw(canvas) - } - - val upStatus: Int - get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED - val downStatus: Int - get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED - val leftStatus: Int - get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED - val rightStatus: Int - get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED - - fun onConfigureTouch(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val fingerPositionX = event.getX(pointerIndex).toInt() - val fingerPositionY = event.getY(pointerIndex).toInt() - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - } - - MotionEvent.ACTION_MOVE -> { - controlPositionX += fingerPositionX - previousTouchX - controlPositionY += fingerPositionY - previousTouchY - setBounds( - controlPositionX, - controlPositionY, - width + controlPositionX, - height + controlPositionY - ) - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - } - } - return true - } - - fun setPosition(x: Int, y: Int) { - controlPositionX = x - controlPositionY = y - } - - fun setBounds(left: Int, top: Int, right: Int, bottom: Int) { - defaultStateBitmap.setBounds(left, top, right, bottom) - pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom) - pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom) - } - - fun setOpacity(value: Int) { - defaultStateBitmap.alpha = value - pressedOneDirectionStateBitmap.alpha = value - pressedTwoDirectionsStateBitmap.alpha = value - } - - val bounds: Rect - get() = defaultStateBitmap.bounds - - companion object { - const val VIRT_AXIS_DEADZONE = 0.5f - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt deleted file mode 100644 index 4b07107fc..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt +++ /dev/null @@ -1,292 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay - -import android.content.res.Resources -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Rect -import android.graphics.drawable.BitmapDrawable -import android.view.MotionEvent -import kotlin.math.atan2 -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt -import org.yuzu.yuzu_emu.features.input.NativeInput.ButtonState -import org.yuzu.yuzu_emu.features.input.model.NativeAnalog -import org.yuzu.yuzu_emu.features.input.model.NativeButton -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting - -/** - * Custom [BitmapDrawable] that is capable - * of storing it's own ID. - * - * @param res [Resources] instance. - * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick. - * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick. - * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick. - * @param rectOuter [Rect] which represents the outer joystick bounds. - * @param rectInner [Rect] which represents the inner joystick bounds. - * @param joystick The [NativeAnalog] this Drawable represents. - * @param button The [NativeButton] this Drawable represents. - */ -class InputOverlayDrawableJoystick( - res: Resources, - bitmapOuter: Bitmap, - bitmapInnerDefault: Bitmap, - bitmapInnerPressed: Bitmap, - rectOuter: Rect, - rectInner: Rect, - val joystick: NativeAnalog, - val button: NativeButton, - val prefId: String -) { - // The ID value what motion event is tracking - var trackId = -1 - - var xAxis = 0f - private var yAxis = 0f - - val width: Int - val height: Int - - private var opacity: Int = 0 - - private var virtBounds: Rect - private var origBounds: Rect - - private val outerBitmap: BitmapDrawable - private val defaultStateInnerBitmap: BitmapDrawable - private val pressedStateInnerBitmap: BitmapDrawable - - private var previousTouchX = 0 - private var previousTouchY = 0 - var controlPositionX = 0 - var controlPositionY = 0 - - private val boundsBoxBitmap: BitmapDrawable - - private var pressedState = false - - // TODO: Add button support - val buttonStatus: Int - get() = ButtonState.RELEASED - var bounds: Rect - get() = outerBitmap.bounds - set(bounds) { - outerBitmap.bounds = bounds - } - - // Nintendo joysticks have y axis inverted - val realYAxis: Float - get() = -yAxis - - private val currentStateBitmapDrawable: BitmapDrawable - get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap - - init { - outerBitmap = BitmapDrawable(res, bitmapOuter) - defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault) - pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed) - boundsBoxBitmap = BitmapDrawable(res, bitmapOuter) - width = bitmapOuter.width - height = bitmapOuter.height - bounds = rectOuter - defaultStateInnerBitmap.bounds = rectInner - pressedStateInnerBitmap.bounds = rectInner - virtBounds = bounds - origBounds = outerBitmap.copyBounds() - boundsBoxBitmap.alpha = 0 - boundsBoxBitmap.bounds = virtBounds - setInnerBounds() - } - - fun draw(canvas: Canvas?) { - outerBitmap.draw(canvas!!) - currentStateBitmapDrawable.draw(canvas) - boundsBoxBitmap.draw(canvas) - } - - fun updateStatus(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val xPosition = event.getX(pointerIndex).toInt() - val yPosition = event.getY(pointerIndex).toInt() - val pointerId = event.getPointerId(pointerIndex) - val motionEvent = event.action and MotionEvent.ACTION_MASK - val isActionDown = - motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN - val isActionUp = - motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP - - if (isActionDown) { - if (!bounds.contains(xPosition, yPosition)) { - return false - } - pressedState = true - outerBitmap.alpha = 0 - boundsBoxBitmap.alpha = opacity - if (BooleanSetting.JOYSTICK_REL_CENTER.getBoolean()) { - virtBounds.offset( - xPosition - virtBounds.centerX(), - yPosition - virtBounds.centerY() - ) - } - boundsBoxBitmap.bounds = virtBounds - trackId = pointerId - } - - if (isActionUp) { - if (trackId != pointerId) { - return false - } - pressedState = false - xAxis = 0.0f - yAxis = 0.0f - outerBitmap.alpha = opacity - boundsBoxBitmap.alpha = 0 - virtBounds = Rect( - origBounds.left, - origBounds.top, - origBounds.right, - origBounds.bottom - ) - bounds = Rect( - origBounds.left, - origBounds.top, - origBounds.right, - origBounds.bottom - ) - setInnerBounds() - trackId = -1 - return true - } - - if (trackId == -1) return false - - for (i in 0 until event.pointerCount) { - if (trackId != event.getPointerId(i)) { - continue - } - var touchX = event.getX(i) - var touchY = event.getY(i) - var maxY = virtBounds.bottom.toFloat() - var maxX = virtBounds.right.toFloat() - touchX -= virtBounds.centerX().toFloat() - maxX -= virtBounds.centerX().toFloat() - touchY -= virtBounds.centerY().toFloat() - maxY -= virtBounds.centerY().toFloat() - val axisX = touchX / maxX - val axisY = touchY / maxY - val oldXAxis = xAxis - val oldYAxis = yAxis - - // Clamp the circle pad input to a circle - val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat() - var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat() - if (radius > 1.0f) { - radius = 1.0f - } - xAxis = cos(angle.toDouble()).toFloat() * radius - yAxis = sin(angle.toDouble()).toFloat() * radius - setInnerBounds() - return oldXAxis != xAxis && oldYAxis != yAxis - } - return false - } - - fun onConfigureTouch(event: MotionEvent): Boolean { - val pointerIndex = event.actionIndex - val fingerPositionX = event.getX(pointerIndex).toInt() - val fingerPositionY = event.getY(pointerIndex).toInt() - - when (event.action) { - MotionEvent.ACTION_DOWN -> { - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - controlPositionX = fingerPositionX - (width / 2) - controlPositionY = fingerPositionY - (height / 2) - } - - MotionEvent.ACTION_MOVE -> { - controlPositionX += fingerPositionX - previousTouchX - controlPositionY += fingerPositionY - previousTouchY - bounds = Rect( - controlPositionX, - controlPositionY, - outerBitmap.intrinsicWidth + controlPositionX, - outerBitmap.intrinsicHeight + controlPositionY - ) - virtBounds = Rect( - controlPositionX, - controlPositionY, - outerBitmap.intrinsicWidth + controlPositionX, - outerBitmap.intrinsicHeight + controlPositionY - ) - setInnerBounds() - bounds = Rect( - Rect( - controlPositionX, - controlPositionY, - outerBitmap.intrinsicWidth + controlPositionX, - outerBitmap.intrinsicHeight + controlPositionY - ) - ) - previousTouchX = fingerPositionX - previousTouchY = fingerPositionY - } - } - origBounds = outerBitmap.copyBounds() - return true - } - - private fun setInnerBounds() { - var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt() - var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt() - if (x > virtBounds.centerX() + virtBounds.width() / 2) { - x = - virtBounds.centerX() + virtBounds.width() / 2 - } - if (x < virtBounds.centerX() - virtBounds.width() / 2) { - x = - virtBounds.centerX() - virtBounds.width() / 2 - } - if (y > virtBounds.centerY() + virtBounds.height() / 2) { - y = - virtBounds.centerY() + virtBounds.height() / 2 - } - if (y < virtBounds.centerY() - virtBounds.height() / 2) { - y = - virtBounds.centerY() - virtBounds.height() / 2 - } - val width = pressedStateInnerBitmap.bounds.width() / 2 - val height = pressedStateInnerBitmap.bounds.height() / 2 - defaultStateInnerBitmap.setBounds( - x - width, - y - height, - x + width, - y + height - ) - pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds - } - - fun setPosition(x: Int, y: Int) { - controlPositionX = x - controlPositionY = y - } - - fun setOpacity(value: Int) { - opacity = value - - defaultStateInnerBitmap.alpha = value - pressedStateInnerBitmap.alpha = value - - if (trackId == -1) { - outerBitmap.alpha = value - boundsBoxBitmap.alpha = 0 - } else { - outerBitmap.alpha = 0 - boundsBoxBitmap.alpha = value - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControl.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControl.kt deleted file mode 100644 index a0eeadf4b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControl.kt +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay.model - -import androidx.annotation.IntegerRes -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication - -enum class OverlayControl( - val id: String, - val defaultVisibility: Boolean, - @IntegerRes val defaultLandscapePositionResources: Pair, - @IntegerRes val defaultPortraitPositionResources: Pair, - @IntegerRes val defaultFoldablePositionResources: Pair -) { - BUTTON_A( - "button_a", - true, - Pair(R.integer.BUTTON_A_X, R.integer.BUTTON_A_Y), - Pair(R.integer.BUTTON_A_X_PORTRAIT, R.integer.BUTTON_A_Y_PORTRAIT), - Pair(R.integer.BUTTON_A_X_FOLDABLE, R.integer.BUTTON_A_Y_FOLDABLE) - ), - BUTTON_B( - "button_b", - true, - Pair(R.integer.BUTTON_B_X, R.integer.BUTTON_B_Y), - Pair(R.integer.BUTTON_B_X_PORTRAIT, R.integer.BUTTON_B_Y_PORTRAIT), - Pair(R.integer.BUTTON_B_X_FOLDABLE, R.integer.BUTTON_B_Y_FOLDABLE) - ), - BUTTON_X( - "button_x", - true, - Pair(R.integer.BUTTON_X_X, R.integer.BUTTON_X_Y), - Pair(R.integer.BUTTON_X_X_PORTRAIT, R.integer.BUTTON_X_Y_PORTRAIT), - Pair(R.integer.BUTTON_X_X_FOLDABLE, R.integer.BUTTON_X_Y_FOLDABLE) - ), - BUTTON_Y( - "button_y", - true, - Pair(R.integer.BUTTON_Y_X, R.integer.BUTTON_Y_Y), - Pair(R.integer.BUTTON_Y_X_PORTRAIT, R.integer.BUTTON_Y_Y_PORTRAIT), - Pair(R.integer.BUTTON_Y_X_FOLDABLE, R.integer.BUTTON_Y_Y_FOLDABLE) - ), - BUTTON_PLUS( - "button_plus", - true, - Pair(R.integer.BUTTON_PLUS_X, R.integer.BUTTON_PLUS_Y), - Pair(R.integer.BUTTON_PLUS_X_PORTRAIT, R.integer.BUTTON_PLUS_Y_PORTRAIT), - Pair(R.integer.BUTTON_PLUS_X_FOLDABLE, R.integer.BUTTON_PLUS_Y_FOLDABLE) - ), - BUTTON_MINUS( - "button_minus", - true, - Pair(R.integer.BUTTON_MINUS_X, R.integer.BUTTON_MINUS_Y), - Pair(R.integer.BUTTON_MINUS_X_PORTRAIT, R.integer.BUTTON_MINUS_Y_PORTRAIT), - Pair(R.integer.BUTTON_MINUS_X_FOLDABLE, R.integer.BUTTON_MINUS_Y_FOLDABLE) - ), - BUTTON_HOME( - "button_home", - false, - Pair(R.integer.BUTTON_HOME_X, R.integer.BUTTON_HOME_Y), - Pair(R.integer.BUTTON_HOME_X_PORTRAIT, R.integer.BUTTON_HOME_Y_PORTRAIT), - Pair(R.integer.BUTTON_HOME_X_FOLDABLE, R.integer.BUTTON_HOME_Y_FOLDABLE) - ), - BUTTON_CAPTURE( - "button_capture", - false, - Pair(R.integer.BUTTON_CAPTURE_X, R.integer.BUTTON_CAPTURE_Y), - Pair(R.integer.BUTTON_CAPTURE_X_PORTRAIT, R.integer.BUTTON_CAPTURE_Y_PORTRAIT), - Pair(R.integer.BUTTON_CAPTURE_X_FOLDABLE, R.integer.BUTTON_CAPTURE_Y_FOLDABLE) - ), - BUTTON_L( - "button_l", - true, - Pair(R.integer.BUTTON_L_X, R.integer.BUTTON_L_Y), - Pair(R.integer.BUTTON_L_X_PORTRAIT, R.integer.BUTTON_L_Y_PORTRAIT), - Pair(R.integer.BUTTON_L_X_FOLDABLE, R.integer.BUTTON_L_Y_FOLDABLE) - ), - BUTTON_R( - "button_r", - true, - Pair(R.integer.BUTTON_R_X, R.integer.BUTTON_R_Y), - Pair(R.integer.BUTTON_R_X_PORTRAIT, R.integer.BUTTON_R_Y_PORTRAIT), - Pair(R.integer.BUTTON_R_X_FOLDABLE, R.integer.BUTTON_R_Y_FOLDABLE) - ), - BUTTON_ZL( - "button_zl", - true, - Pair(R.integer.BUTTON_ZL_X, R.integer.BUTTON_ZL_Y), - Pair(R.integer.BUTTON_ZL_X_PORTRAIT, R.integer.BUTTON_ZL_Y_PORTRAIT), - Pair(R.integer.BUTTON_ZL_X_FOLDABLE, R.integer.BUTTON_ZL_Y_FOLDABLE) - ), - BUTTON_ZR( - "button_zr", - true, - Pair(R.integer.BUTTON_ZR_X, R.integer.BUTTON_ZR_Y), - Pair(R.integer.BUTTON_ZR_X_PORTRAIT, R.integer.BUTTON_ZR_Y_PORTRAIT), - Pair(R.integer.BUTTON_ZR_X_FOLDABLE, R.integer.BUTTON_ZR_Y_FOLDABLE) - ), - BUTTON_STICK_L( - "button_stick_l", - true, - Pair(R.integer.BUTTON_STICK_L_X, R.integer.BUTTON_STICK_L_Y), - Pair(R.integer.BUTTON_STICK_L_X_PORTRAIT, R.integer.BUTTON_STICK_L_Y_PORTRAIT), - Pair(R.integer.BUTTON_STICK_L_X_FOLDABLE, R.integer.BUTTON_STICK_L_Y_FOLDABLE) - ), - BUTTON_STICK_R( - "button_stick_r", - true, - Pair(R.integer.BUTTON_STICK_R_X, R.integer.BUTTON_STICK_R_Y), - Pair(R.integer.BUTTON_STICK_R_X_PORTRAIT, R.integer.BUTTON_STICK_R_Y_PORTRAIT), - Pair(R.integer.BUTTON_STICK_R_X_FOLDABLE, R.integer.BUTTON_STICK_R_Y_FOLDABLE) - ), - STICK_L( - "stick_l", - true, - Pair(R.integer.STICK_L_X, R.integer.STICK_L_Y), - Pair(R.integer.STICK_L_X_PORTRAIT, R.integer.STICK_L_Y_PORTRAIT), - Pair(R.integer.STICK_L_X_FOLDABLE, R.integer.STICK_L_Y_FOLDABLE) - ), - STICK_R( - "stick_r", - true, - Pair(R.integer.STICK_R_X, R.integer.STICK_R_Y), - Pair(R.integer.STICK_R_X_PORTRAIT, R.integer.STICK_R_Y_PORTRAIT), - Pair(R.integer.STICK_R_X_FOLDABLE, R.integer.STICK_R_Y_FOLDABLE) - ), - COMBINED_DPAD( - "combined_dpad", - true, - Pair(R.integer.COMBINED_DPAD_X, R.integer.COMBINED_DPAD_Y), - Pair(R.integer.COMBINED_DPAD_X_PORTRAIT, R.integer.COMBINED_DPAD_Y_PORTRAIT), - Pair(R.integer.COMBINED_DPAD_X_FOLDABLE, R.integer.COMBINED_DPAD_Y_FOLDABLE) - ); - - fun getDefaultPositionForLayout(layout: OverlayLayout): Pair { - val rawResourcePair: Pair - YuzuApplication.appContext.resources.apply { - rawResourcePair = when (layout) { - OverlayLayout.Landscape -> { - Pair( - getInteger(this@OverlayControl.defaultLandscapePositionResources.first), - getInteger(this@OverlayControl.defaultLandscapePositionResources.second) - ) - } - - OverlayLayout.Portrait -> { - Pair( - getInteger(this@OverlayControl.defaultPortraitPositionResources.first), - getInteger(this@OverlayControl.defaultPortraitPositionResources.second) - ) - } - - OverlayLayout.Foldable -> { - Pair( - getInteger(this@OverlayControl.defaultFoldablePositionResources.first), - getInteger(this@OverlayControl.defaultFoldablePositionResources.second) - ) - } - } - } - - return Pair( - rawResourcePair.first.toDouble() / 1000, - rawResourcePair.second.toDouble() / 1000 - ) - } - - fun toOverlayControlData(): OverlayControlData = - OverlayControlData( - id, - defaultVisibility, - getDefaultPositionForLayout(OverlayLayout.Landscape), - getDefaultPositionForLayout(OverlayLayout.Portrait), - getDefaultPositionForLayout(OverlayLayout.Foldable) - ) - - companion object { - val map: HashMap by lazy { - val hashMap = hashMapOf() - entries.forEach { hashMap[it.id] = it } - hashMap - } - - fun from(id: String): OverlayControl? = map[id] - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlData.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlData.kt deleted file mode 100644 index 26cfeb1db..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlData.kt +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay.model - -data class OverlayControlData( - val id: String, - var enabled: Boolean, - var landscapePosition: Pair, - var portraitPosition: Pair, - var foldablePosition: Pair -) { - fun positionFromLayout(layout: OverlayLayout): Pair = - when (layout) { - OverlayLayout.Landscape -> landscapePosition - OverlayLayout.Portrait -> portraitPosition - OverlayLayout.Foldable -> foldablePosition - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlDefault.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlDefault.kt deleted file mode 100644 index 6bd74c82f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayControlDefault.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay.model - -import androidx.annotation.IntegerRes - -data class OverlayControlDefault( - val buttonId: String, - @IntegerRes val landscapePositionResource: Pair, - @IntegerRes val portraitPositionResource: Pair, - @IntegerRes val foldablePositionResource: Pair -) diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayLayout.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayLayout.kt deleted file mode 100644 index d728164e5..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/model/OverlayLayout.kt +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.overlay.model - -enum class OverlayLayout(val id: String) { - Landscape("Landscape"), - Portrait("Portrait"), - Foldable("Foldable") -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt deleted file mode 100644 index fadb20e39..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import com.google.android.material.color.MaterialColors -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.adapters.GameAdapter -import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding -import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import org.yuzu.yuzu_emu.utils.ViewUtils.updateMargins -import org.yuzu.yuzu_emu.utils.collect - -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 onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentGamesBinding.inflate(inflater) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - homeViewModel.setNavigationVisibility(visible = true, animated = true) - homeViewModel.setStatusBarShadeVisibility(true) - - binding.gridGames.apply { - layoutManager = AutofitGridLayoutManager( - requireContext(), - requireContext().resources.getDimensionPixelSize(R.dimen.card_width) - ) - adapter = GameAdapter(requireActivity() as AppCompatActivity) - } - - binding.swipeRefresh.apply { - // Add swipe down to refresh gesture - setOnRefreshListener { - gamesViewModel.reloadGames(false) - } - - // Set theme color to the refresh animation's background - setProgressBackgroundColorSchemeColor( - MaterialColors.getColor( - binding.swipeRefresh, - com.google.android.material.R.attr.colorPrimary - ) - ) - setColorSchemeColors( - MaterialColors.getColor( - binding.swipeRefresh, - com.google.android.material.R.attr.colorOnPrimary - ) - ) - - // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn - post { - if (_binding == null) { - return@post - } - binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value - } - } - - gamesViewModel.isReloading.collect(viewLifecycleOwner) { - binding.swipeRefresh.isRefreshing = it - binding.noticeText.setVisible( - visible = gamesViewModel.games.value.isEmpty() && !it, - gone = false - ) - } - gamesViewModel.games.collect(viewLifecycleOwner) { - (binding.gridGames.adapter as GameAdapter).submitList(it) - } - gamesViewModel.shouldSwapData.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setShouldSwapData(false) } - ) { - if (it) { - (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value) - } - } - gamesViewModel.shouldScrollToTop.collect( - viewLifecycleOwner, - resetState = { gamesViewModel.setShouldScrollToTop(false) } - ) { if (it) scrollToTop() } - - setInsets() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - private fun scrollToTop() { - if (_binding != null) { - binding.gridGames.smoothScrollToPosition(0) - } - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { view: View, windowInsets: WindowInsetsCompat -> - val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) - val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large) - val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) - val spacingNavigationRail = - resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) - - binding.gridGames.updatePadding( - top = barInsets.top + extraListSpacing, - bottom = barInsets.bottom + spacingNavigation + extraListSpacing - ) - - binding.swipeRefresh.setProgressViewEndTarget( - false, - barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end) - ) - - val leftInsets = barInsets.left + cutoutInsets.left - val rightInsets = barInsets.right + cutoutInsets.right - val left: Int - val right: Int - if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { - left = leftInsets + spacingNavigationRail - right = rightInsets - } else { - left = leftInsets - right = rightInsets + spacingNavigationRail - } - binding.swipeRefresh.updateMargins(left = left, right = right) - - binding.noticeText.updatePadding(bottom = spacingNavigation) - - windowInsets - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt deleted file mode 100644 index 757463a0b..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt +++ /dev/null @@ -1,692 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.ui.main - -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import android.view.WindowManager -import android.view.animation.PathInterpolator -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.navigation.NavController -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupWithNavController -import androidx.preference.PreferenceManager -import com.google.android.material.color.MaterialColors -import com.google.android.material.navigation.NavigationBarView -import java.io.File -import java.io.FilenameFilter -import org.yuzu.yuzu_emu.HomeNavigationDirections -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.databinding.ActivityMainBinding -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.fragments.AddGameFolderDialogFragment -import org.yuzu.yuzu_emu.fragments.ProgressDialogFragment -import org.yuzu.yuzu_emu.fragments.MessageDialogFragment -import org.yuzu.yuzu_emu.model.AddonViewModel -import org.yuzu.yuzu_emu.model.DriverViewModel -import org.yuzu.yuzu_emu.model.GamesViewModel -import org.yuzu.yuzu_emu.model.HomeViewModel -import org.yuzu.yuzu_emu.model.InstallResult -import org.yuzu.yuzu_emu.model.TaskState -import org.yuzu.yuzu_emu.model.TaskViewModel -import org.yuzu.yuzu_emu.utils.* -import org.yuzu.yuzu_emu.utils.ViewUtils.setVisible -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream - -class MainActivity : AppCompatActivity(), ThemeProvider { - private lateinit var binding: ActivityMainBinding - - private val homeViewModel: HomeViewModel by viewModels() - private val gamesViewModel: GamesViewModel by viewModels() - private val taskViewModel: TaskViewModel by viewModels() - private val addonViewModel: AddonViewModel by viewModels() - private val driverViewModel: DriverViewModel by viewModels() - - override var themeId: Int = 0 - - private val CHECKED_DECRYPTION = "CheckedDecryption" - private var checkedDecryption = false - - override fun onCreate(savedInstanceState: Bundle?) { - val splashScreen = installSplashScreen() - splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady } - - ThemeHelper.setTheme(this) - - super.onCreate(savedInstanceState) - - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - if (savedInstanceState != null) { - checkedDecryption = savedInstanceState.getBoolean(CHECKED_DECRYPTION) - } - if (!checkedDecryption) { - val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) - .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) - if (!firstTimeSetup) { - checkKeys() - } - checkedDecryption = true - } - - WindowCompat.setDecorFitsSystemWindows(window, false) - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) - - window.statusBarColor = - ContextCompat.getColor(applicationContext, android.R.color.transparent) - window.navigationBarColor = - ContextCompat.getColor(applicationContext, android.R.color.transparent) - - binding.statusBarShade.setBackgroundColor( - ThemeHelper.getColorWithOpacity( - MaterialColors.getColor( - binding.root, - com.google.android.material.R.attr.colorSurface - ), - ThemeHelper.SYSTEM_BAR_ALPHA - ) - ) - if (InsetsHelper.getSystemGestureType(applicationContext) != - InsetsHelper.GESTURE_NAVIGATION - ) { - binding.navigationBarShade.setBackgroundColor( - ThemeHelper.getColorWithOpacity( - MaterialColors.getColor( - binding.root, - com.google.android.material.R.attr.colorSurface - ), - ThemeHelper.SYSTEM_BAR_ALPHA - ) - ) - } - - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment - setUpNavigation(navHostFragment.navController) - (binding.navigationView as NavigationBarView).setOnItemReselectedListener { - when (it.itemId) { - R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true) - R.id.searchFragment -> gamesViewModel.setSearchFocused(true) - R.id.homeSettingsFragment -> { - val action = HomeNavigationDirections.actionGlobalSettingsActivity( - null, - Settings.MenuTag.SECTION_ROOT - ) - navHostFragment.navController.navigate(action) - } - } - } - - // Prevents navigation from being drawn for a short time on recreation if set to hidden - if (!homeViewModel.navigationVisible.value.first) { - binding.navigationView.setVisible(visible = false, gone = false) - binding.statusBarShade.setVisible(visible = false, gone = false) - } - - homeViewModel.navigationVisible.collect(this) { showNavigation(it.first, it.second) } - homeViewModel.statusBarShadeVisible.collect(this) { showStatusBarShade(it) } - homeViewModel.contentToInstall.collect( - this, - resetState = { homeViewModel.setContentToInstall(null) } - ) { - if (it != null) { - installContent(it) - } - } - homeViewModel.checkKeys.collect(this, resetState = { homeViewModel.setCheckKeys(false) }) { - if (it) checkKeys() - } - - setInsets() - } - - private fun checkKeys() { - if (!NativeLibrary.areKeysPresent()) { - MessageDialogFragment.newInstance( - titleId = R.string.keys_missing, - descriptionId = R.string.keys_missing_description, - helpLinkId = R.string.keys_missing_help - ).show(supportFragmentManager, MessageDialogFragment.TAG) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putBoolean(CHECKED_DECRYPTION, checkedDecryption) - } - - fun finishSetup(navController: NavController) { - navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment) - (binding.navigationView as NavigationBarView).setupWithNavController(navController) - showNavigation(visible = true, animated = true) - } - - private fun setUpNavigation(navController: NavController) { - val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext) - .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true) - - if (firstTimeSetup && !homeViewModel.navigatedToSetup) { - navController.navigate(R.id.firstTimeSetupFragment) - homeViewModel.navigatedToSetup = true - } else { - (binding.navigationView as NavigationBarView).setupWithNavController(navController) - } - } - - private fun showNavigation(visible: Boolean, animated: Boolean) { - if (!animated) { - binding.navigationView.setVisible(visible) - return - } - - val smallLayout = resources.getBoolean(R.bool.small_layout) - binding.navigationView.animate().apply { - if (visible) { - binding.navigationView.setVisible(true) - duration = 300 - interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) - - if (smallLayout) { - binding.navigationView.translationY = - binding.navigationView.height.toFloat() * 2 - translationY(0f) - } else { - if (ViewCompat.getLayoutDirection(binding.navigationView) == - ViewCompat.LAYOUT_DIRECTION_LTR - ) { - binding.navigationView.translationX = - binding.navigationView.width.toFloat() * -2 - translationX(0f) - } else { - binding.navigationView.translationX = - binding.navigationView.width.toFloat() * 2 - translationX(0f) - } - } - } else { - duration = 300 - interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) - - if (smallLayout) { - translationY(binding.navigationView.height.toFloat() * 2) - } else { - if (ViewCompat.getLayoutDirection(binding.navigationView) == - ViewCompat.LAYOUT_DIRECTION_LTR - ) { - translationX(binding.navigationView.width.toFloat() * -2) - } else { - translationX(binding.navigationView.width.toFloat() * 2) - } - } - } - }.withEndAction { - if (!visible) { - binding.navigationView.setVisible(visible = false, gone = false) - } - }.start() - } - - private fun showStatusBarShade(visible: Boolean) { - binding.statusBarShade.animate().apply { - if (visible) { - binding.statusBarShade.setVisible(true) - binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2 - duration = 300 - translationY(0f) - interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f) - } else { - duration = 300 - translationY(binding.navigationView.height.toFloat() * -2) - interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f) - } - }.withEndAction { - if (!visible) { - binding.statusBarShade.setVisible(visible = false, gone = false) - } - }.start() - } - - override fun onResume() { - ThemeHelper.setCorrectTheme(this) - super.onResume() - } - - private fun setInsets() = - ViewCompat.setOnApplyWindowInsetsListener( - binding.root - ) { _: View, windowInsets: WindowInsetsCompat -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams - mlpStatusShade.height = insets.top - binding.statusBarShade.layoutParams = mlpStatusShade - - // The only situation where we care to have a nav bar shade is when it's at the bottom - // of the screen where scrolling list elements can go behind it. - val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams - mlpNavShade.height = insets.bottom - binding.navigationBarShade.layoutParams = mlpNavShade - - windowInsets - } - - override fun setTheme(resId: Int) { - super.setTheme(resId) - themeId = resId - } - - val getGamesDirectory = - registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result -> - if (result != null) { - processGamesDir(result) - } - } - - fun processGamesDir(result: Uri) { - contentResolver.takePersistableUriPermission( - result, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - - val uriString = result.toString() - val folder = gamesViewModel.folders.value.firstOrNull { it.uriString == uriString } - if (folder != null) { - Toast.makeText( - applicationContext, - R.string.folder_already_added, - Toast.LENGTH_SHORT - ).show() - return - } - - AddGameFolderDialogFragment.newInstance(uriString) - .show(supportFragmentManager, AddGameFolderDialogFragment.TAG) - } - - val getProdKey = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result != null) { - processKey(result) - } - } - - fun processKey(result: Uri): Boolean { - if (FileUtil.getExtension(result) != "keys") { - MessageDialogFragment.newInstance( - this, - titleId = R.string.reading_keys_failure, - descriptionId = R.string.install_prod_keys_failure_extension_description - ).show(supportFragmentManager, MessageDialogFragment.TAG) - return false - } - - contentResolver.takePersistableUriPermission( - result, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - - val dstPath = DirectoryInitialization.userDirectory + "/keys/" - if (FileUtil.copyUriToInternalStorage( - result, - dstPath, - "prod.keys" - ) != null - ) { - if (NativeLibrary.reloadKeys()) { - Toast.makeText( - applicationContext, - R.string.install_keys_success, - Toast.LENGTH_SHORT - ).show() - homeViewModel.setCheckKeys(true) - gamesViewModel.reloadGames(true) - return true - } else { - MessageDialogFragment.newInstance( - this, - titleId = R.string.invalid_keys_error, - descriptionId = R.string.install_keys_failure_description, - helpLinkId = R.string.dumping_keys_quickstart_link - ).show(supportFragmentManager, MessageDialogFragment.TAG) - return false - } - } - return false - } - - val getFirmware = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") } - - val firmwarePath = - File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/") - val cacheFirmwareDir = File("${cacheDir.path}/registered/") - - ProgressDialogFragment.newInstance( - this, - R.string.firmware_installing - ) { progressCallback, _ -> - var messageToShow: Any - try { - FileUtil.unzipToInternalStorage( - result.toString(), - cacheFirmwareDir, - progressCallback - ) - val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1 - val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2 - messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) { - MessageDialogFragment.newInstance( - this, - titleId = R.string.firmware_installed_failure, - descriptionId = R.string.firmware_installed_failure_description - ) - } else { - firmwarePath.deleteRecursively() - cacheFirmwareDir.copyRecursively(firmwarePath, true) - NativeLibrary.initializeSystem(true) - homeViewModel.setCheckKeys(true) - getString(R.string.save_file_imported_success) - } - } catch (e: Exception) { - Log.error("[MainActivity] Firmware install failed - ${e.message}") - messageToShow = getString(R.string.fatal_error) - } finally { - cacheFirmwareDir.deleteRecursively() - } - messageToShow - }.show(supportFragmentManager, ProgressDialogFragment.TAG) - } - - val getAmiiboKey = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - if (FileUtil.getExtension(result) != "bin") { - MessageDialogFragment.newInstance( - this, - titleId = R.string.reading_keys_failure, - descriptionId = R.string.install_amiibo_keys_failure_extension_description - ).show(supportFragmentManager, MessageDialogFragment.TAG) - return@registerForActivityResult - } - - contentResolver.takePersistableUriPermission( - result, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - - val dstPath = DirectoryInitialization.userDirectory + "/keys/" - if (FileUtil.copyUriToInternalStorage( - result, - dstPath, - "key_retail.bin" - ) != null - ) { - if (NativeLibrary.reloadKeys()) { - Toast.makeText( - applicationContext, - R.string.install_keys_success, - Toast.LENGTH_SHORT - ).show() - } else { - MessageDialogFragment.newInstance( - this, - titleId = R.string.invalid_keys_error, - descriptionId = R.string.install_keys_failure_description, - helpLinkId = R.string.dumping_keys_quickstart_link - ).show(supportFragmentManager, MessageDialogFragment.TAG) - } - } - } - - val installGameUpdate = registerForActivityResult( - ActivityResultContracts.OpenMultipleDocuments() - ) { documents: List -> - if (documents.isEmpty()) { - return@registerForActivityResult - } - - if (addonViewModel.game == null) { - installContent(documents) - return@registerForActivityResult - } - - ProgressDialogFragment.newInstance( - this@MainActivity, - R.string.verifying_content, - false - ) { _, _ -> - var updatesMatchProgram = true - for (document in documents) { - val valid = NativeLibrary.doesUpdateMatchProgram( - addonViewModel.game!!.programId, - document.toString() - ) - if (!valid) { - updatesMatchProgram = false - break - } - } - - if (updatesMatchProgram) { - homeViewModel.setContentToInstall(documents) - } else { - MessageDialogFragment.newInstance( - this@MainActivity, - titleId = R.string.content_install_notice, - descriptionId = R.string.content_install_notice_description, - positiveAction = { homeViewModel.setContentToInstall(documents) }, - negativeAction = {} - ) - } - }.show(supportFragmentManager, ProgressDialogFragment.TAG) - } - - private fun installContent(documents: List) { - ProgressDialogFragment.newInstance( - this@MainActivity, - R.string.installing_game_content - ) { progressCallback, messageCallback -> - var installSuccess = 0 - var installOverwrite = 0 - var errorBaseGame = 0 - var error = 0 - documents.forEach { - messageCallback.invoke(FileUtil.getFilename(it)) - when ( - InstallResult.from( - NativeLibrary.installFileToNand( - it.toString(), - progressCallback - ) - ) - ) { - InstallResult.Success -> { - installSuccess += 1 - } - - InstallResult.Overwrite -> { - installOverwrite += 1 - } - - InstallResult.BaseInstallAttempted -> { - errorBaseGame += 1 - } - - InstallResult.Failure -> { - error += 1 - } - } - } - - addonViewModel.refreshAddons() - - val separator = System.getProperty("line.separator") ?: "\n" - val installResult = StringBuilder() - if (installSuccess > 0) { - installResult.append( - getString( - R.string.install_game_content_success_install, - installSuccess - ) - ) - installResult.append(separator) - } - if (installOverwrite > 0) { - installResult.append( - getString( - R.string.install_game_content_success_overwrite, - installOverwrite - ) - ) - installResult.append(separator) - } - val errorTotal: Int = errorBaseGame + error - if (errorTotal > 0) { - installResult.append(separator) - installResult.append( - getString( - R.string.install_game_content_failed_count, - errorTotal - ) - ) - installResult.append(separator) - if (errorBaseGame > 0) { - installResult.append(separator) - installResult.append( - getString(R.string.install_game_content_failure_base) - ) - installResult.append(separator) - } - if (error > 0) { - installResult.append( - getString(R.string.install_game_content_failure_description) - ) - installResult.append(separator) - } - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_failure, - descriptionString = installResult.toString().trim(), - helpLinkId = R.string.install_game_content_help_link - ) - } else { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.install_game_content_success, - descriptionString = installResult.toString().trim() - ) - } - }.show(supportFragmentManager, ProgressDialogFragment.TAG) - } - - val exportUserData = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip") - ) { result -> - if (result == null) { - return@registerForActivityResult - } - - ProgressDialogFragment.newInstance( - this, - R.string.exporting_user_data, - true - ) { progressCallback, _ -> - val zipResult = FileUtil.zipFromInternalStorage( - File(DirectoryInitialization.userDirectory!!), - DirectoryInitialization.userDirectory!!, - BufferedOutputStream(contentResolver.openOutputStream(result)), - progressCallback, - compression = false - ) - return@newInstance when (zipResult) { - TaskState.Completed -> getString(R.string.user_data_export_success) - TaskState.Failed -> R.string.export_failed - TaskState.Cancelled -> R.string.user_data_export_cancelled - } - }.show(supportFragmentManager, ProgressDialogFragment.TAG) - } - - val importUserData = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { result -> - if (result == null) { - return@registerForActivityResult - } - - ProgressDialogFragment.newInstance( - this, - R.string.importing_user_data - ) { progressCallback, _ -> - val checkStream = - ZipInputStream(BufferedInputStream(contentResolver.openInputStream(result))) - var isYuzuBackup = false - checkStream.use { stream -> - var ze: ZipEntry? = null - while (stream.nextEntry?.also { ze = it } != null) { - val itemName = ze!!.name.trim() - if (itemName == "/config/config.ini" || itemName == "config/config.ini") { - isYuzuBackup = true - return@use - } - } - } - if (!isYuzuBackup) { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.invalid_yuzu_backup, - descriptionId = R.string.user_data_import_failed_description - ) - } - - // Clear existing user data - NativeConfig.unloadGlobalConfig() - File(DirectoryInitialization.userDirectory!!).deleteRecursively() - - // Copy archive to internal storage - try { - FileUtil.unzipToInternalStorage( - result.toString(), - File(DirectoryInitialization.userDirectory!!), - progressCallback - ) - } catch (e: Exception) { - return@newInstance MessageDialogFragment.newInstance( - this, - titleId = R.string.import_failed, - descriptionId = R.string.user_data_import_failed_description - ) - } - - // Reinitialize relevant data - NativeLibrary.initializeSystem(true) - NativeConfig.initializeGlobalConfig() - gamesViewModel.reloadGames(false) - driverViewModel.reloadDriverData() - - return@newInstance getString(R.string.user_data_import_success) - }.show(supportFragmentManager, ProgressDialogFragment.TAG) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt deleted file mode 100644 index 511a6e4fa..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.ui.main - -interface ThemeProvider { - /** - * Provides theme ID by overriding an activity's 'setTheme' method and returning that result - */ - var themeId: Int -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt deleted file mode 100644 index 8cc5ea71f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/AddonUtil.kt +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -object AddonUtil { - val validAddonDirectories = listOf("cheats", "exefs", "romfs") -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt deleted file mode 100644 index de0794a17..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import androidx.preference.PreferenceManager -import java.io.IOException -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.features.settings.model.Settings -import org.yuzu.yuzu_emu.overlay.model.OverlayControlData -import org.yuzu.yuzu_emu.overlay.model.OverlayControl -import org.yuzu.yuzu_emu.overlay.model.OverlayLayout -import org.yuzu.yuzu_emu.utils.PreferenceUtil.migratePreference - -object DirectoryInitialization { - private var userPath: String? = null - - var areDirectoriesReady: Boolean = false - - fun start() { - if (!areDirectoriesReady) { - initializeInternalStorage() - NativeLibrary.initializeSystem(false) - NativeConfig.initializeGlobalConfig() - migrateSettings() - areDirectoriesReady = true - } - } - - val userDirectory: String? - get() { - check(areDirectoriesReady) { "Directory initialization is not ready!" } - return userPath - } - - private fun initializeInternalStorage() { - try { - userPath = YuzuApplication.appContext.getExternalFilesDir(null)!!.canonicalPath - NativeLibrary.setAppDirectory(userPath!!) - } catch (e: IOException) { - e.printStackTrace() - } - } - - private fun migrateSettings() { - val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) - var saveConfig = false - val theme = preferences.migratePreference(Settings.PREF_THEME) - if (theme != null) { - IntSetting.THEME.setInt(theme) - saveConfig = true - } - - val themeMode = preferences.migratePreference(Settings.PREF_THEME_MODE) - if (themeMode != null) { - IntSetting.THEME_MODE.setInt(themeMode) - saveConfig = true - } - - val blackBackgrounds = - preferences.migratePreference(Settings.PREF_BLACK_BACKGROUNDS) - if (blackBackgrounds != null) { - BooleanSetting.BLACK_BACKGROUNDS.setBoolean(blackBackgrounds) - saveConfig = true - } - - val joystickRelCenter = - preferences.migratePreference(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER) - if (joystickRelCenter != null) { - BooleanSetting.JOYSTICK_REL_CENTER.setBoolean(joystickRelCenter) - saveConfig = true - } - - val dpadSlide = - preferences.migratePreference(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE) - if (dpadSlide != null) { - BooleanSetting.DPAD_SLIDE.setBoolean(dpadSlide) - saveConfig = true - } - - val hapticFeedback = - preferences.migratePreference(Settings.PREF_MENU_SETTINGS_HAPTICS) - if (hapticFeedback != null) { - BooleanSetting.HAPTIC_FEEDBACK.setBoolean(hapticFeedback) - saveConfig = true - } - - val showPerformanceOverlay = - preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_FPS) - if (showPerformanceOverlay != null) { - BooleanSetting.SHOW_PERFORMANCE_OVERLAY.setBoolean(showPerformanceOverlay) - saveConfig = true - } - - val showInputOverlay = - preferences.migratePreference(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY) - if (showInputOverlay != null) { - BooleanSetting.SHOW_INPUT_OVERLAY.setBoolean(showInputOverlay) - saveConfig = true - } - - val overlayOpacity = preferences.migratePreference(Settings.PREF_CONTROL_OPACITY) - if (overlayOpacity != null) { - IntSetting.OVERLAY_OPACITY.setInt(overlayOpacity) - saveConfig = true - } - - val overlayScale = preferences.migratePreference(Settings.PREF_CONTROL_SCALE) - if (overlayScale != null) { - IntSetting.OVERLAY_SCALE.setInt(overlayScale) - saveConfig = true - } - - var setOverlayData = false - val overlayControlData = NativeConfig.getOverlayControlData() - if (overlayControlData.isEmpty()) { - val overlayControlDataMap = - NativeConfig.getOverlayControlData().associateBy { it.id }.toMutableMap() - for (button in Settings.overlayPreferences) { - val buttonId = convertButtonId(button) - var buttonEnabled = preferences.migratePreference(button) - if (buttonEnabled == null) { - buttonEnabled = OverlayControl.map[buttonId]?.defaultVisibility == true - } - - var landscapeXPosition = preferences.migratePreference( - "$button-X${Settings.PREF_LANDSCAPE_SUFFIX}" - )?.toDouble() - var landscapeYPosition = preferences.migratePreference( - "$button-Y${Settings.PREF_LANDSCAPE_SUFFIX}" - )?.toDouble() - if (landscapeXPosition == null || landscapeYPosition == null) { - val landscapePosition = OverlayControl.map[buttonId] - ?.getDefaultPositionForLayout(OverlayLayout.Landscape) ?: Pair(0.0, 0.0) - landscapeXPosition = landscapePosition.first - landscapeYPosition = landscapePosition.second - } - - var portraitXPosition = preferences.migratePreference( - "$button-X${Settings.PREF_PORTRAIT_SUFFIX}" - )?.toDouble() - var portraitYPosition = preferences.migratePreference( - "$button-Y${Settings.PREF_PORTRAIT_SUFFIX}" - )?.toDouble() - if (portraitXPosition == null || portraitYPosition == null) { - val portraitPosition = OverlayControl.map[buttonId] - ?.getDefaultPositionForLayout(OverlayLayout.Portrait) ?: Pair(0.0, 0.0) - portraitXPosition = portraitPosition.first - portraitYPosition = portraitPosition.second - } - - var foldableXPosition = preferences.migratePreference( - "$button-X${Settings.PREF_FOLDABLE_SUFFIX}" - )?.toDouble() - var foldableYPosition = preferences.migratePreference( - "$button-Y${Settings.PREF_FOLDABLE_SUFFIX}" - )?.toDouble() - if (foldableXPosition == null || foldableYPosition == null) { - val foldablePosition = OverlayControl.map[buttonId] - ?.getDefaultPositionForLayout(OverlayLayout.Foldable) ?: Pair(0.0, 0.0) - foldableXPosition = foldablePosition.first - foldableYPosition = foldablePosition.second - } - - val controlData = OverlayControlData( - buttonId, - buttonEnabled, - Pair(landscapeXPosition, landscapeYPosition), - Pair(portraitXPosition, portraitYPosition), - Pair(foldableXPosition, foldableYPosition) - ) - overlayControlDataMap[buttonId] = controlData - setOverlayData = true - } - - if (setOverlayData) { - NativeConfig.setOverlayControlData( - overlayControlDataMap.map { it.value }.toTypedArray() - ) - saveConfig = true - } - } - - if (saveConfig) { - NativeConfig.saveGlobalConfig() - } - } - - private fun convertButtonId(buttonId: String): String = - when (buttonId) { - Settings.PREF_BUTTON_A -> OverlayControl.BUTTON_A.id - Settings.PREF_BUTTON_B -> OverlayControl.BUTTON_B.id - Settings.PREF_BUTTON_X -> OverlayControl.BUTTON_X.id - Settings.PREF_BUTTON_Y -> OverlayControl.BUTTON_Y.id - Settings.PREF_BUTTON_L -> OverlayControl.BUTTON_L.id - Settings.PREF_BUTTON_R -> OverlayControl.BUTTON_R.id - Settings.PREF_BUTTON_ZL -> OverlayControl.BUTTON_ZL.id - Settings.PREF_BUTTON_ZR -> OverlayControl.BUTTON_ZR.id - Settings.PREF_BUTTON_PLUS -> OverlayControl.BUTTON_PLUS.id - Settings.PREF_BUTTON_MINUS -> OverlayControl.BUTTON_MINUS.id - Settings.PREF_BUTTON_DPAD -> OverlayControl.COMBINED_DPAD.id - Settings.PREF_STICK_L -> OverlayControl.STICK_L.id - Settings.PREF_STICK_R -> OverlayControl.STICK_R.id - Settings.PREF_BUTTON_HOME -> OverlayControl.BUTTON_HOME.id - Settings.PREF_BUTTON_SCREENSHOT -> OverlayControl.BUTTON_CAPTURE.id - Settings.PREF_BUTTON_STICK_L -> OverlayControl.BUTTON_STICK_L.id - Settings.PREF_BUTTON_STICK_R -> OverlayControl.BUTTON_STICK_R.id - else -> "" - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt deleted file mode 100644 index 738275297..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.net.Uri -import androidx.documentfile.provider.DocumentFile -import java.io.File -import java.util.* -import org.yuzu.yuzu_emu.model.MinimalDocumentFile - -class DocumentsTree { - private var root: DocumentsNode? = null - - fun setRoot(rootUri: Uri?) { - root = null - root = DocumentsNode() - root!!.uri = rootUri - root!!.isDirectory = true - } - - fun openContentUri(filepath: String, openMode: String?): Int { - val node = resolvePath(filepath) ?: return -1 - return FileUtil.openContentUri(node.uri.toString(), openMode) - } - - fun getFileSize(filepath: String): Long { - val node = resolvePath(filepath) - return if (node == null || node.isDirectory) { - 0 - } else { - FileUtil.getFileSize(node.uri.toString()) - } - } - - fun exists(filepath: String): Boolean { - return resolvePath(filepath) != null - } - - fun isDirectory(filepath: String): Boolean { - val node = resolvePath(filepath) - return node != null && node.isDirectory - } - - fun getParentDirectory(filepath: String): String { - val node = resolvePath(filepath)!! - val parentNode = node.parent - if (parentNode != null && parentNode.isDirectory) { - return parentNode.uri!!.toString() - } - return node.uri!!.toString() - } - - fun getFilename(filepath: String): String { - val node = resolvePath(filepath) - if (node != null) { - return node.name!! - } - return filepath - } - - private fun resolvePath(filepath: String): DocumentsNode? { - val tokens = StringTokenizer(filepath, File.separator, false) - var iterator = root - while (tokens.hasMoreTokens()) { - val token = tokens.nextToken() - if (token.isEmpty()) continue - iterator = find(iterator, token) - if (iterator == null) return null - } - return iterator - } - - private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? { - if (parent!!.isDirectory && !parent.loaded) { - structTree(parent) - } - return parent.children[filename] - } - - /** - * Construct current level directory tree - * @param parent parent node of this level - */ - private fun structTree(parent: DocumentsNode) { - val documents = FileUtil.listFiles(parent.uri!!) - for (document in documents) { - val node = DocumentsNode(document) - node.parent = parent - parent.children[node.name] = node - } - parent.loaded = true - } - - private class DocumentsNode { - var parent: DocumentsNode? = null - val children: MutableMap = HashMap() - var name: String? = null - var uri: Uri? = null - var loaded = false - var isDirectory = false - - constructor() - constructor(document: MinimalDocumentFile) { - name = document.filename - uri = document.uri - isDirectory = document.isDirectory - loaded = !isDirectory - } - - private constructor(document: DocumentFile, isCreateDir: Boolean) { - name = document.name - uri = document.uri - isDirectory = isCreateDir - loaded = true - } - - private fun rename(name: String) { - if (parent == null) { - return - } - parent!!.children.remove(this.name) - this.name = name - parent!!.children[name] = this - } - } - - companion object { - fun isNativePath(path: String): Boolean { - return if (path.isNotEmpty()) { - path[0] == '/' - } else { - false - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt deleted file mode 100644 index fc2339f5a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt +++ /dev/null @@ -1,503 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.database.Cursor -import android.net.Uri -import android.provider.DocumentsContract -import androidx.documentfile.provider.DocumentFile -import java.io.BufferedInputStream -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.net.URLDecoder -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.model.MinimalDocumentFile -import org.yuzu.yuzu_emu.model.TaskState -import java.io.BufferedOutputStream -import java.io.OutputStream -import java.lang.NullPointerException -import java.nio.charset.StandardCharsets -import java.util.zip.Deflater -import java.util.zip.ZipOutputStream -import kotlin.IllegalStateException - -object FileUtil { - const val PATH_TREE = "tree" - const val DECODE_METHOD = "UTF-8" - const val APPLICATION_OCTET_STREAM = "application/octet-stream" - const val TEXT_PLAIN = "text/plain" - - private val context get() = YuzuApplication.appContext - - /** - * Create a file from directory with filename. - * @param context Application context - * @param directory parent path for file. - * @param filename file display name. - * @return boolean - */ - fun createFile(directory: String?, filename: String): DocumentFile? { - var decodedFilename = filename - try { - val directoryUri = Uri.parse(directory) - val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null - decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD) - var mimeType = APPLICATION_OCTET_STREAM - if (decodedFilename.endsWith(".txt")) { - mimeType = TEXT_PLAIN - } - val exists = parent.findFile(decodedFilename) - return exists ?: parent.createFile(mimeType, decodedFilename) - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot create file, error: " + e.message) - } - return null - } - - /** - * Create a directory from directory with filename. - * @param directory parent path for directory. - * @param directoryName directory display name. - * @return boolean - */ - fun createDir(directory: String?, directoryName: String?): DocumentFile? { - var decodedDirectoryName = directoryName - try { - val directoryUri = Uri.parse(directory) - val parent = DocumentFile.fromTreeUri(context, directoryUri) ?: return null - decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD) - val isExist = parent.findFile(decodedDirectoryName) - return isExist ?: parent.createDirectory(decodedDirectoryName) - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot create file, error: " + e.message) - } - return null - } - - /** - * Open content uri and return file descriptor to JNI. - * @param path Native content uri path - * @param openMode will be one of "r", "r", "rw", "wa", "rwa" - * @return file descriptor - */ - @JvmStatic - fun openContentUri(path: String, openMode: String?): Int { - try { - val uri = Uri.parse(path) - val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!) - if (parcelFileDescriptor == null) { - Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path") - return -1 - } - val fileDescriptor = parcelFileDescriptor.detachFd() - parcelFileDescriptor.close() - return fileDescriptor - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot open content uri, error: " + e.message) - } - return -1 - } - - /** - * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow - * This function will be faster than DocumentFile.listFiles - * @param uri Directory uri. - * @return CheapDocument lists. - */ - fun listFiles(uri: Uri): Array { - val resolver = context.contentResolver - val columns = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE - ) - var c: Cursor? = null - val results: MutableList = ArrayList() - try { - val docId: String = if (isRootTreeUri(uri)) { - DocumentsContract.getTreeDocumentId(uri) - } else { - DocumentsContract.getDocumentId(uri) - } - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) - c = resolver.query(childrenUri, columns, null, null, null) - while (c!!.moveToNext()) { - val documentId = c.getString(0) - val documentName = c.getString(1) - val documentMimeType = c.getString(2) - val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId) - val document = MinimalDocumentFile(documentName, documentMimeType, documentUri) - results.add(document) - } - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot list file error: " + e.message) - } finally { - closeQuietly(c) - } - return results.toTypedArray() - } - - /** - * Check whether given path exists. - * @param path Native content uri path - * @return bool - */ - fun exists(path: String?, suppressLog: Boolean = false): Boolean { - var c: Cursor? = null - try { - val mUri = Uri.parse(path) - val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) - c = context.contentResolver.query(mUri, columns, null, null, null) - return c!!.count > 0 - } catch (e: Exception) { - if (!suppressLog) { - Log.info("[FileUtil] Cannot find file from given path, error: " + e.message) - } - } finally { - closeQuietly(c) - } - return false - } - - /** - * Check whether given path is a directory - * @param path content uri path - * @return bool - */ - fun isDirectory(path: String): Boolean { - val resolver = context.contentResolver - val columns = arrayOf( - DocumentsContract.Document.COLUMN_MIME_TYPE - ) - var isDirectory = false - var c: Cursor? = null - try { - val mUri = Uri.parse(path) - c = resolver.query(mUri, columns, null, null, null) - c!!.moveToNext() - val mimeType = c.getString(0) - isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot list files, error: " + e.message) - } finally { - closeQuietly(c) - } - return isDirectory - } - - /** - * Get file display name from given path - * @param uri content uri - * @return String display name - */ - fun getFilename(uri: Uri): String { - val resolver = YuzuApplication.appContext.contentResolver - val columns = arrayOf( - DocumentsContract.Document.COLUMN_DISPLAY_NAME - ) - var filename = "" - var c: Cursor? = null - try { - c = resolver.query(uri, columns, null, null, null) - c!!.moveToNext() - filename = c.getString(0) - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot get file size, error: " + e.message) - } finally { - closeQuietly(c) - } - return filename - } - - fun getFilesName(path: String): Array { - val uri = Uri.parse(path) - val files: MutableList = ArrayList() - for (file in listFiles(uri)) { - files.add(file.filename) - } - return files.toTypedArray() - } - - /** - * Get file size from given path. - * @param path content uri path - * @return long file size - */ - @JvmStatic - fun getFileSize(path: String): Long { - val resolver = context.contentResolver - val columns = arrayOf( - DocumentsContract.Document.COLUMN_SIZE - ) - var size: Long = 0 - var c: Cursor? = null - try { - val mUri = Uri.parse(path) - c = resolver.query(mUri, columns, null, null, null) - c!!.moveToNext() - size = c.getLong(0) - } catch (e: Exception) { - Log.error("[FileUtil]: Cannot get file size, error: " + e.message) - } finally { - closeQuietly(c) - } - return size - } - - /** - * Creates an input stream with a given [Uri] and copies its data to the given path. This will - * overwrite any pre-existing files. - * - * @param sourceUri The [Uri] to copy data from - * @param destinationParentPath Destination directory - * @param destinationFilename Optionally renames the file once copied - */ - fun copyUriToInternalStorage( - sourceUri: Uri, - destinationParentPath: String, - destinationFilename: String = "" - ): File? = - try { - val fileName = - if (destinationFilename == "") getFilename(sourceUri) else "/$destinationFilename" - val inputStream = context.contentResolver.openInputStream(sourceUri)!! - - val destinationFile = File("$destinationParentPath$fileName") - if (destinationFile.exists()) { - destinationFile.delete() - } - - destinationFile.outputStream().use { fos -> - inputStream.use { it.copyTo(fos) } - } - destinationFile - } catch (e: IOException) { - null - } catch (e: NullPointerException) { - null - } - - /** - * Extracts the given zip file into the given directory. - * @param path String representation of a [Uri] or a typical path delimited by '/' - * @param destDir Location to unzip the contents of [path] into - * @param progressCallback Lambda that is called with the total number of files and the current - * progress through the process. Stops execution as soon as possible if this returns true. - */ - @Throws(SecurityException::class) - fun unzipToInternalStorage( - path: String, - destDir: File, - progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } - ) { - var totalEntries = 0L - ZipInputStream(getInputStream(path)).use { zis -> - var tempEntry = zis.nextEntry - while (tempEntry != null) { - tempEntry = zis.nextEntry - totalEntries++ - } - } - - var progress = 0L - ZipInputStream(getInputStream(path)).use { zis -> - var entry: ZipEntry? = zis.nextEntry - while (entry != null) { - if (progressCallback.invoke(totalEntries, progress)) { - return@use - } - - val newFile = File(destDir, entry.name) - val destinationDirectory = if (entry.isDirectory) newFile else newFile.parentFile - - if (!newFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) { - throw SecurityException("Zip file attempted path traversal! ${entry.name}") - } - - if (!destinationDirectory.isDirectory && !destinationDirectory.mkdirs()) { - throw IOException("Failed to create directory $destinationDirectory") - } - - if (!entry.isDirectory) { - newFile.outputStream().use { fos -> zis.copyTo(fos) } - } - entry = zis.nextEntry - progress++ - } - } - } - - /** - * Creates a zip file from a directory within internal storage - * @param inputFile File representation of the item that will be zipped - * @param rootDir Directory containing the inputFile - * @param outputStream Stream where the zip file will be output - * @param progressCallback Lambda that is called with the total number of files and the current - * progress through the process. Stops execution as soon as possible if this returns true. - * @param compression Disables compression if true - */ - fun zipFromInternalStorage( - inputFile: File, - rootDir: String, - outputStream: BufferedOutputStream, - progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false }, - compression: Boolean = true - ): TaskState { - try { - ZipOutputStream(outputStream).use { zos -> - if (!compression) { - zos.setMethod(ZipOutputStream.DEFLATED) - zos.setLevel(Deflater.NO_COMPRESSION) - } - - var count = 0L - val totalFiles = inputFile.walkTopDown().count().toLong() - inputFile.walkTopDown().forEach { file -> - if (progressCallback.invoke(totalFiles, count)) { - return TaskState.Cancelled - } - - if (!file.isDirectory) { - val entryName = - file.absolutePath.removePrefix(rootDir).removePrefix("/") - val entry = ZipEntry(entryName) - zos.putNextEntry(entry) - if (file.isFile) { - file.inputStream().use { fis -> fis.copyTo(zos) } - } - count++ - } - } - } - } catch (e: Exception) { - Log.error("[FileUtil] Failed creating zip file - ${e.message}") - return TaskState.Failed - } - return TaskState.Completed - } - - /** - * Helper function that copies the contents of a DocumentFile folder into a [File] - * @param file [File] representation of the folder to copy into - * @param progressCallback Lambda that is called with the total number of files and the current - * progress through the process. Stops execution as soon as possible if this returns true. - * @throws IllegalStateException Fails when trying to copy a folder into a file and vice versa - */ - fun DocumentFile.copyFilesTo( - file: File, - progressCallback: (max: Long, progress: Long) -> Boolean = { _, _ -> false } - ) { - file.mkdirs() - if (!this.isDirectory || !file.isDirectory) { - throw IllegalStateException( - "[FileUtil] Tried to copy a folder into a file or vice versa" - ) - } - - var count = 0L - val totalFiles = this.listFiles().size.toLong() - this.listFiles().forEach { - if (progressCallback.invoke(totalFiles, count)) { - return - } - - val newFile = File(file, it.name!!) - if (it.isDirectory) { - newFile.mkdirs() - DocumentFile.fromTreeUri(YuzuApplication.appContext, it.uri)?.copyFilesTo(newFile) - } else { - val inputStream = - YuzuApplication.appContext.contentResolver.openInputStream(it.uri) - BufferedInputStream(inputStream).use { bos -> - if (!newFile.exists()) { - newFile.createNewFile() - } - newFile.outputStream().use { os -> bos.copyTo(os) } - } - } - count++ - } - } - - fun isRootTreeUri(uri: Uri): Boolean { - val paths = uri.pathSegments - return paths.size == 2 && PATH_TREE == paths[0] - } - - fun closeQuietly(closeable: AutoCloseable?) { - if (closeable != null) { - try { - closeable.close() - } catch (rethrown: RuntimeException) { - throw rethrown - } catch (ignored: Exception) { - } - } - } - - fun getExtension(uri: Uri): String { - val fileName = getFilename(uri) - return fileName.substring(fileName.lastIndexOf(".") + 1) - .lowercase() - } - - fun isTreeUriValid(uri: Uri): Boolean { - val resolver = context.contentResolver - val columns = arrayOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID, - DocumentsContract.Document.COLUMN_DISPLAY_NAME, - DocumentsContract.Document.COLUMN_MIME_TYPE - ) - return try { - val docId: String = if (isRootTreeUri(uri)) { - DocumentsContract.getTreeDocumentId(uri) - } else { - DocumentsContract.getDocumentId(uri) - } - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) - resolver.query(childrenUri, columns, null, null, null) - true - } catch (_: Exception) { - false - } - } - - fun getInputStream(path: String) = if (path.contains("content://")) { - Uri.parse(path).inputStream() - } else { - File(path).inputStream() - } - - fun getOutputStream(path: String) = if (path.contains("content://")) { - Uri.parse(path).outputStream() - } else { - File(path).outputStream() - } - - @Throws(IOException::class) - fun getStringFromFile(file: File): String = - String(file.readBytes(), StandardCharsets.UTF_8) - - @Throws(IOException::class) - fun getStringFromInputStream(stream: InputStream): String = - String(stream.readBytes(), StandardCharsets.UTF_8) - - fun DocumentFile.inputStream(): InputStream = - YuzuApplication.appContext.contentResolver.openInputStream(uri)!! - - fun DocumentFile.outputStream(): OutputStream = - YuzuApplication.appContext.contentResolver.openOutputStream(uri)!! - - fun Uri.inputStream(): InputStream = - YuzuApplication.appContext.contentResolver.openInputStream(this)!! - - fun Uri.outputStream(): OutputStream = - YuzuApplication.appContext.contentResolver.openOutputStream(this)!! - - fun Uri.asDocumentFile(): DocumentFile? = - DocumentFile.fromSingleUri(YuzuApplication.appContext, this) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt deleted file mode 100644 index 579b600f1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt +++ /dev/null @@ -1,152 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.content.SharedPreferences -import android.net.Uri -import androidx.preference.PreferenceManager -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.model.Game -import org.yuzu.yuzu_emu.model.GameDir -import org.yuzu.yuzu_emu.model.MinimalDocumentFile - -object GameHelper { - private const val KEY_OLD_GAME_PATH = "game_path" - const val KEY_GAMES = "Games" - - private lateinit var preferences: SharedPreferences - - fun getGames(): List { - val games = mutableListOf() - val context = YuzuApplication.appContext - preferences = PreferenceManager.getDefaultSharedPreferences(context) - - val gameDirs = mutableListOf() - val oldGamesDir = preferences.getString(KEY_OLD_GAME_PATH, "") ?: "" - if (oldGamesDir.isNotEmpty()) { - gameDirs.add(GameDir(oldGamesDir, true)) - preferences.edit().remove(KEY_OLD_GAME_PATH).apply() - } - gameDirs.addAll(NativeConfig.getGameDirs()) - - // Ensure keys are loaded so that ROM metadata can be decrypted. - NativeLibrary.reloadKeys() - - // Reset metadata so we don't use stale information - GameMetadata.resetMetadata() - - // Remove previous filesystem provider information so we can get up to date version info - NativeLibrary.clearFilesystemProvider() - - val badDirs = mutableListOf() - gameDirs.forEachIndexed { index: Int, gameDir: GameDir -> - val gameDirUri = Uri.parse(gameDir.uriString) - val isValid = FileUtil.isTreeUriValid(gameDirUri) - if (isValid) { - addGamesRecursive( - games, - FileUtil.listFiles(gameDirUri), - if (gameDir.deepScan) 3 else 1 - ) - } else { - badDirs.add(index) - } - } - - // Remove all game dirs with insufficient permissions from config - if (badDirs.isNotEmpty()) { - var offset = 0 - badDirs.forEach { - gameDirs.removeAt(it - offset) - offset++ - } - } - NativeConfig.setGameDirs(gameDirs.toTypedArray()) - - // Cache list of games found on disk - val serializedGames = mutableSetOf() - games.forEach { - serializedGames.add(Json.encodeToString(it)) - } - preferences.edit() - .remove(KEY_GAMES) - .putStringSet(KEY_GAMES, serializedGames) - .apply() - - return games.toList() - } - - private fun addGamesRecursive( - games: MutableList, - files: Array, - depth: Int - ) { - if (depth <= 0) { - return - } - - files.forEach { - if (it.isDirectory) { - addGamesRecursive( - games, - FileUtil.listFiles(it.uri), - depth - 1 - ) - } else { - if (Game.extensions.contains(FileUtil.getExtension(it.uri))) { - val game = getGame(it.uri, true) - if (game != null) { - games.add(game) - } - } - } - } - } - - fun getGame(uri: Uri, addedToLibrary: Boolean): Game? { - val filePath = uri.toString() - if (!GameMetadata.getIsValid(filePath)) { - return null - } - - // Needed to update installed content information - NativeLibrary.addFileToFilesystemProvider(filePath) - - var name = GameMetadata.getTitle(filePath) - - // If the game's title field is empty, use the filename. - if (name.isEmpty()) { - name = FileUtil.getFilename(uri) - } - var programId = GameMetadata.getProgramId(filePath) - - // If the game's ID field is empty, use the filename without extension. - if (programId.isEmpty()) { - programId = name.substring(0, name.lastIndexOf(".")) - } - - val newGame = Game( - name, - filePath, - programId, - GameMetadata.getDeveloper(filePath), - GameMetadata.getVersion(filePath, false), - GameMetadata.getIsHomebrew(filePath) - ) - - if (addedToLibrary) { - val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L) - if (addedTime == 0L) { - preferences.edit() - .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis()) - .apply() - } - } - - return newGame - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt deleted file mode 100644 index d05020560..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameIconUtils.kt +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.drawable.LayerDrawable -import android.widget.ImageView -import androidx.core.content.res.ResourcesCompat -import androidx.core.graphics.drawable.IconCompat -import androidx.core.graphics.drawable.toBitmap -import androidx.core.graphics.drawable.toDrawable -import androidx.lifecycle.LifecycleOwner -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 org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.model.Game - -class GameIconFetcher( - private val game: Game, - private val options: Options -) : Fetcher { - override suspend fun fetch(): FetchResult { - return DrawableResult( - drawable = decodeGameIcon(game.path)!!.toDrawable(options.context.resources), - isSampled = false, - dataSource = DataSource.DISK - ) - } - - private fun decodeGameIcon(uri: String): Bitmap? { - val data = GameMetadata.getIcon(uri) - return BitmapFactory.decodeByteArray( - data, - 0, - data.size, - BitmapFactory.Options() - ) - } - - 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 { - private val imageLoader = ImageLoader.Builder(YuzuApplication.appContext) - .components { - add(GameIconKeyer()) - add(GameIconFetcher.Factory()) - } - .memoryCache { - MemoryCache.Builder(YuzuApplication.appContext) - .maxSizePercent(0.25) - .build() - } - .build() - - fun loadGameIcon(game: Game, imageView: ImageView) { - val request = ImageRequest.Builder(YuzuApplication.appContext) - .data(game) - .target(imageView) - .error(R.drawable.default_icon) - .build() - imageLoader.enqueue(request) - } - - suspend fun getGameIcon(lifecycleOwner: LifecycleOwner, game: Game): Bitmap { - val request = ImageRequest.Builder(YuzuApplication.appContext) - .data(game) - .lifecycle(lifecycleOwner) - .error(R.drawable.default_icon) - .build() - return imageLoader.execute(request) - .drawable!!.toBitmap(config = Bitmap.Config.ARGB_8888) - } - - suspend fun getShortcutIcon(lifecycleOwner: LifecycleOwner, game: Game): IconCompat { - val layerDrawable = ResourcesCompat.getDrawable( - YuzuApplication.appContext.resources, - R.drawable.shortcut, - null - ) as LayerDrawable - layerDrawable.setDrawableByLayerId( - R.id.shortcut_foreground, - getGameIcon(lifecycleOwner, game).toDrawable(YuzuApplication.appContext.resources) - ) - val inset = YuzuApplication.appContext.resources - .getDimensionPixelSize(R.dimen.icon_inset) - layerDrawable.setLayerInset(1, inset, inset, inset, inset) - return IconCompat.createWithAdaptiveBitmap( - layerDrawable.toBitmap(config = Bitmap.Config.ARGB_8888) - ) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameMetadata.kt deleted file mode 100644 index 8e412482a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameMetadata.kt +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -object GameMetadata { - external fun getIsValid(path: String): Boolean - - external fun getTitle(path: String): String - - external fun getProgramId(path: String): String - - external fun getDeveloper(path: String): String - - external fun getVersion(path: String, reload: Boolean): String - - external fun getIcon(path: String): ByteArray - - external fun getIsHomebrew(path: String): Boolean - - external fun resetMetadata() -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt deleted file mode 100644 index a72dea8f1..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt +++ /dev/null @@ -1,229 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.graphics.SurfaceTexture -import android.net.Uri -import android.os.Build -import android.view.Surface -import java.io.File -import java.io.IOException -import org.yuzu.yuzu_emu.NativeLibrary -import org.yuzu.yuzu_emu.YuzuApplication -import org.yuzu.yuzu_emu.features.settings.model.StringSetting -import java.io.FileNotFoundException -import java.util.zip.ZipException -import java.util.zip.ZipFile - -object GpuDriverHelper { - private const val META_JSON_FILENAME = "meta.json" - private var fileRedirectionPath: String? = null - var driverInstallationPath: String? = null - private var hookLibPath: String? = null - - val driverStoragePath get() = DirectoryInitialization.userDirectory!! + "/gpu_drivers/" - - fun initializeDriverParameters() { - try { - // Initialize the file redirection directory. - fileRedirectionPath = YuzuApplication.appContext - .getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/" - - // Initialize the driver installation directory. - driverInstallationPath = YuzuApplication.appContext - .filesDir.canonicalPath + "/gpu_driver/" - } catch (e: IOException) { - throw RuntimeException(e) - } - - // Initialize directories. - initializeDirectories() - - // Initialize hook libraries directory. - hookLibPath = YuzuApplication.appContext.applicationInfo.nativeLibraryDir + "/" - - // Initialize GPU driver. - NativeLibrary.initializeGpuDriver( - hookLibPath, - driverInstallationPath, - installedCustomDriverData.libraryName, - fileRedirectionPath - ) - } - - fun getDrivers(): MutableList> { - val driverZips = File(driverStoragePath).listFiles() - val drivers: MutableList> = - driverZips - ?.mapNotNull { - val metadata = getMetadataFromZip(it) - metadata.name?.let { _ -> Pair(it.path, metadata) } - } - ?.sortedByDescending { it: Pair -> it.second.name } - ?.distinct() - ?.toMutableList() ?: mutableListOf() - return drivers - } - - fun installDefaultDriver() { - // Removing the installed driver will result in the backend using the default system driver. - File(driverInstallationPath!!).deleteRecursively() - initializeDriverParameters() - } - - fun copyDriverToInternalStorage(driverUri: Uri): Boolean { - // Ensure we have directories. - initializeDirectories() - - // Copy the zip file URI to user data - val copiedFile = - FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false - - // Validate driver - val metadata = getMetadataFromZip(copiedFile) - if (metadata.name == null) { - copiedFile.delete() - return false - } - - if (metadata.minApi > Build.VERSION.SDK_INT) { - copiedFile.delete() - return false - } - return true - } - - /** - * Copies driver zip into user data directory so that it can be exported along with - * other user data and also unzipped into the installation directory - */ - fun installCustomDriver(driverUri: Uri): Boolean { - // Revert to system default in the event the specified driver is bad. - installDefaultDriver() - - // Ensure we have directories. - initializeDirectories() - - // Copy the zip file URI to user data - val copiedFile = - FileUtil.copyUriToInternalStorage(driverUri, driverStoragePath) ?: return false - - // Validate driver - val metadata = getMetadataFromZip(copiedFile) - if (metadata.name == null) { - copiedFile.delete() - return false - } - - if (metadata.minApi > Build.VERSION.SDK_INT) { - copiedFile.delete() - return false - } - - // Unzip the driver. - try { - FileUtil.unzipToInternalStorage( - copiedFile.path, - File(driverInstallationPath!!) - ) - } catch (e: SecurityException) { - return false - } - - // Initialize the driver parameters. - initializeDriverParameters() - - return true - } - - /** - * Unzips driver into installation directory - */ - fun installCustomDriver(driver: File): Boolean { - // Revert to system default in the event the specified driver is bad. - installDefaultDriver() - - // Ensure we have directories. - initializeDirectories() - - // Validate driver - val metadata = getMetadataFromZip(driver) - if (metadata.name == null) { - driver.delete() - return false - } - - // Unzip the driver to the private installation directory - try { - FileUtil.unzipToInternalStorage( - driver.path, - File(driverInstallationPath!!) - ) - } catch (e: SecurityException) { - return false - } - - // Initialize the driver parameters. - initializeDriverParameters() - - return true - } - - /** - * Takes in a zip file and reads the meta.json file for presentation to the UI - * - * @param driver Zip containing driver and meta.json file - * @return A non-null [GpuDriverMetadata] instance that may have null members - */ - fun getMetadataFromZip(driver: File): GpuDriverMetadata { - try { - ZipFile(driver).use { zf -> - val entries = zf.entries() - while (entries.hasMoreElements()) { - val entry = entries.nextElement() - if (!entry.isDirectory && entry.name.lowercase().contains(".json")) { - zf.getInputStream(entry).use { - return GpuDriverMetadata(it, entry.size) - } - } - } - } - } catch (_: ZipException) { - } catch (_: FileNotFoundException) { - } - return GpuDriverMetadata() - } - - external fun supportsCustomDriverLoading(): Boolean - - external fun getSystemDriverInfo( - surface: Surface = Surface(SurfaceTexture(true)), - hookLibPath: String = GpuDriverHelper.hookLibPath!! - ): Array? - - // Parse the custom driver metadata to retrieve the name. - val installedCustomDriverData: GpuDriverMetadata - get() = GpuDriverMetadata(File(driverInstallationPath + META_JSON_FILENAME)) - - val customDriverSettingData: GpuDriverMetadata - get() = getMetadataFromZip(File(StringSetting.DRIVER_PATH.getString())) - - fun initializeDirectories() { - // Ensure the file redirection directory exists. - val fileRedirectionDir = File(fileRedirectionPath!!) - if (!fileRedirectionDir.exists()) { - fileRedirectionDir.mkdirs() - } - // Ensure the driver installation directory exists. - val driverInstallationDir = File(driverInstallationPath!!) - if (!driverInstallationDir.exists()) { - driverInstallationDir.mkdirs() - } - // Ensure the driver storage directory exists - val driverStorageDirectory = File(driverStoragePath) - if (!driverStorageDirectory.exists()) { - driverStorageDirectory.mkdirs() - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt deleted file mode 100644 index 511a4171a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_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)) - 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/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt deleted file mode 100644 index 2c7356e6a..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.view.InputDevice -import android.view.KeyEvent -import android.view.MotionEvent -import org.yuzu.yuzu_emu.features.input.NativeInput -import org.yuzu.yuzu_emu.features.input.YuzuInputOverlayDevice -import org.yuzu.yuzu_emu.features.input.YuzuPhysicalDevice - -object InputHandler { - var androidControllers = mapOf() - var registeredControllers = mutableListOf() - - fun dispatchKeyEvent(event: KeyEvent): Boolean { - val action = when (event.action) { - KeyEvent.ACTION_DOWN -> NativeInput.ButtonState.PRESSED - KeyEvent.ACTION_UP -> NativeInput.ButtonState.RELEASED - else -> return false - } - - var controllerData = androidControllers[event.device.controllerNumber] - if (controllerData == null) { - updateControllerData() - controllerData = androidControllers[event.device.controllerNumber] ?: return false - } - - NativeInput.onGamePadButtonEvent( - controllerData.getGUID(), - controllerData.getPort(), - event.keyCode, - action - ) - return true - } - - fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { - val controllerData = - androidControllers[event.device.controllerNumber] ?: return false - event.device.motionRanges.forEach { - NativeInput.onGamePadAxisEvent( - controllerData.getGUID(), - controllerData.getPort(), - it.axis, - event.getAxisValue(it.axis) - ) - } - return true - } - - fun getDevices(): Map { - val gameControllerDeviceIds = mutableMapOf() - val deviceIds = InputDevice.getDeviceIds() - var port = 0 - val inputSettings = NativeConfig.getInputSettings(true) - deviceIds.forEach { deviceId -> - InputDevice.getDevice(deviceId)?.apply { - // Verify that the device has gamepad buttons, control sticks, or both. - if (sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || - sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK - ) { - if (!gameControllerDeviceIds.contains(controllerNumber)) { - gameControllerDeviceIds[controllerNumber] = YuzuPhysicalDevice( - this, - port, - inputSettings[port].useSystemVibrator - ) - } - port++ - } - } - } - return gameControllerDeviceIds - } - - fun updateControllerData() { - androidControllers = getDevices() - androidControllers.forEach { - NativeInput.registerController(it.value) - } - - // Register the input overlay on a dedicated port for all player 1 vibrations - NativeInput.registerController(YuzuInputOverlayDevice(androidControllers.isEmpty(), 100)) - registeredControllers.clear() - NativeInput.getInputDevices().forEach { - registeredControllers.add(ParamPackage(it)) - } - registeredControllers.sortBy { it.get("port", 0) } - } - - fun InputDevice.getGUID(): String = String.format("%016x%016x", productId, vendorId) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt deleted file mode 100644 index 595f0d284..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.annotation.SuppressLint -import android.content.Context - -object InsetsHelper { - const val THREE_BUTTON_NAVIGATION = 0 - const val TWO_BUTTON_NAVIGATION = 1 - const val GESTURE_NAVIGATION = 2 - - @SuppressLint("DiscouragedApi") - fun getSystemGestureType(context: Context): Int { - val resources = context.resources - val resourceId = - resources.getIdentifier("config_navBarInteractionMode", "integer", "android") - return if (resourceId != 0) { - resources.getInteger(resourceId) - } else { - 0 - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/LifecycleUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/LifecycleUtils.kt deleted file mode 100644 index d5c19c681..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/LifecycleUtils.kt +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch - -/** - * Collects this [Flow] with a given [LifecycleOwner]. - * @param scope [LifecycleOwner] that this [Flow] will be collected with. - * @param repeatState When to repeat collection on this [Flow]. - * @param resetState Optional lambda to reset state of an underlying [MutableStateFlow] after - * [stateCollector] has been run. - * @param stateCollector Lambda that receives new state. - */ -inline fun Flow.collect( - scope: LifecycleOwner, - repeatState: Lifecycle.State = Lifecycle.State.CREATED, - crossinline resetState: () -> Unit = {}, - crossinline stateCollector: (state: T) -> Unit -) { - scope.apply { - lifecycleScope.launch { - repeatOnLifecycle(repeatState) { - this@collect.collect { - stateCollector(it) - resetState() - } - } - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt deleted file mode 100644 index aebe84b0f..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.os.Build - -object Log { - // Tracks whether we should share the old log or the current log - var gameLaunched = false - - external fun debug(message: String) - - external fun warning(message: String) - - external fun info(message: String) - - external fun error(message: String) - - external fun critical(message: String) - - fun logDeviceInfo() { - info("Device Manufacturer - ${Build.MANUFACTURER}") - info("Device Model - ${Build.MODEL}") - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) { - info("SoC Manufacturer - ${Build.SOC_MANUFACTURER}") - info("SoC Model - ${Build.SOC_MODEL}") - } - info("Total System Memory - ${MemoryUtil.getDeviceRAM()}") - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/MemoryUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/MemoryUtil.kt deleted file mode 100644 index 0b94c73e5..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/MemoryUtil.kt +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.app.ActivityManager -import android.content.Context -import android.os.Build -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.YuzuApplication -import java.util.Locale -import kotlin.math.ceil - -object MemoryUtil { - private val context get() = YuzuApplication.appContext - - private val Float.hundredths: String - get() = String.format(Locale.ROOT, "%.2f", this) - - // Required total system memory - const val REQUIRED_MEMORY = 8 - - const val Kb: Float = 1024F - const val Mb = Kb * 1024 - const val Gb = Mb * 1024 - const val Tb = Gb * 1024 - const val Pb = Tb * 1024 - const val Eb = Pb * 1024 - - fun bytesToSizeUnit(size: Float, roundUp: Boolean = false): String = - when { - size < Kb -> { - context.getString( - R.string.memory_formatted, - size.hundredths, - context.getString(R.string.memory_byte_shorthand) - ) - } - size < Mb -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Kb) else (size / Kb).hundredths, - context.getString(R.string.memory_kilobyte) - ) - } - size < Gb -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Mb) else (size / Mb).hundredths, - context.getString(R.string.memory_megabyte) - ) - } - size < Tb -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Gb) else (size / Gb).hundredths, - context.getString(R.string.memory_gigabyte) - ) - } - size < Pb -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Tb) else (size / Tb).hundredths, - context.getString(R.string.memory_terabyte) - ) - } - size < Eb -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Pb) else (size / Pb).hundredths, - context.getString(R.string.memory_petabyte) - ) - } - else -> { - context.getString( - R.string.memory_formatted, - if (roundUp) ceil(size / Eb) else (size / Eb).hundredths, - context.getString(R.string.memory_exabyte) - ) - } - } - - val totalMemory: Float - get() { - val memInfo = ActivityManager.MemoryInfo() - with(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager) { - getMemoryInfo(memInfo) - } - - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - memInfo.advertisedMem.toFloat() - } else { - memInfo.totalMem.toFloat() - } - } - - fun isLessThan(minimum: Int, size: Float): Boolean = - when (size) { - Kb -> totalMemory < Mb && totalMemory < minimum - Mb -> totalMemory < Gb && (totalMemory / Mb) < minimum - Gb -> totalMemory < Tb && (totalMemory / Gb) < minimum - Tb -> totalMemory < Pb && (totalMemory / Tb) < minimum - Pb -> totalMemory < Eb && (totalMemory / Pb) < minimum - Eb -> totalMemory / Eb < minimum - else -> totalMemory < Kb && totalMemory < minimum - } - - // Devices are unlikely to have 0.5GB increments of memory so we'll just round up to account for - // the potential error created by memInfo.totalMem - fun getDeviceRAM(): String = bytesToSizeUnit(totalMemory, true) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt deleted file mode 100644 index 7228f25d2..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NativeConfig.kt +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import org.yuzu.yuzu_emu.model.GameDir -import org.yuzu.yuzu_emu.overlay.model.OverlayControlData - -import org.yuzu.yuzu_emu.features.input.model.PlayerInput - -object NativeConfig { - /** - * Loads global config. - */ - @Synchronized - external fun initializeGlobalConfig() - - /** - * Destroys the stored global config object. This does not save the existing config. - */ - @Synchronized - external fun unloadGlobalConfig() - - /** - * Reads values in the global config file and saves them. - */ - @Synchronized - external fun reloadGlobalConfig() - - /** - * Saves global settings values in memory to disk. - */ - @Synchronized - external fun saveGlobalConfig() - - /** - * Creates per-game config for the specified parameters. Must be unloaded once per-game config - * is closed with [unloadPerGameConfig]. All switchable values that [NativeConfig] gets/sets - * will follow the per-game config until the global config is reloaded. - * - * @param programId String representation of the u64 programId - * @param fileName Filename of the game, including its extension - */ - @Synchronized - external fun initializePerGameConfig(programId: String, fileName: String) - - @Synchronized - external fun isPerGameConfigLoaded(): Boolean - - /** - * Saves per-game settings values in memory to disk. - */ - @Synchronized - external fun savePerGameConfig() - - /** - * Destroys the stored per-game config object. This does not save the config. - */ - @Synchronized - external fun unloadPerGameConfig() - - @Synchronized - external fun getBoolean(key: String, needsGlobal: Boolean): Boolean - - @Synchronized - external fun setBoolean(key: String, value: Boolean) - - @Synchronized - external fun getByte(key: String, needsGlobal: Boolean): Byte - - @Synchronized - external fun setByte(key: String, value: Byte) - - @Synchronized - external fun getShort(key: String, needsGlobal: Boolean): Short - - @Synchronized - external fun setShort(key: String, value: Short) - - @Synchronized - external fun getInt(key: String, needsGlobal: Boolean): Int - - @Synchronized - external fun setInt(key: String, value: Int) - - @Synchronized - external fun getFloat(key: String, needsGlobal: Boolean): Float - - @Synchronized - external fun setFloat(key: String, value: Float) - - @Synchronized - external fun getLong(key: String, needsGlobal: Boolean): Long - - @Synchronized - external fun setLong(key: String, value: Long) - - @Synchronized - external fun getString(key: String, needsGlobal: Boolean): String - - @Synchronized - external fun setString(key: String, value: String) - - external fun getIsRuntimeModifiable(key: String): Boolean - - external fun getPairedSettingKey(key: String): String - - external fun getIsSwitchable(key: String): Boolean - - @Synchronized - external fun usingGlobal(key: String): Boolean - - @Synchronized - external fun setGlobal(key: String, global: Boolean) - - external fun getIsSaveable(key: String): Boolean - - external fun getDefaultToString(key: String): String - - /** - * Gets every [GameDir] in AndroidSettings::values.game_dirs - */ - @Synchronized - external fun getGameDirs(): Array - - /** - * Clears the AndroidSettings::values.game_dirs array and replaces them with the provided array - */ - @Synchronized - external fun setGameDirs(dirs: Array) - - /** - * Adds a single [GameDir] to the AndroidSettings::values.game_dirs array - */ - @Synchronized - external fun addGameDir(dir: GameDir) - - /** - * Gets an array of the addons that are disabled for a given game - * - * @param programId String representation of a game's program ID - * @return An array of disabled addons - */ - @Synchronized - external fun getDisabledAddons(programId: String): Array - - /** - * Clears the disabled addons array corresponding to [programId] and replaces them - * with [disabledAddons] - * - * @param programId String representation of a game's program ID - * @param disabledAddons Replacement array of disabled addons - */ - @Synchronized - external fun setDisabledAddons(programId: String, disabledAddons: Array) - - /** - * Gets an array of [OverlayControlData] from settings - * - * @return An array of [OverlayControlData] - */ - @Synchronized - external fun getOverlayControlData(): Array - - /** - * Clears the AndroidSettings::values.overlay_control_data array and replaces its values - * with [overlayControlData] - * - * @param overlayControlData Replacement array of [OverlayControlData] - */ - @Synchronized - external fun setOverlayControlData(overlayControlData: Array) - - @Synchronized - external fun getInputSettings(global: Boolean): Array - - @Synchronized - external fun setInputSettings(value: Array, global: Boolean) - - /** - * Saves control values for a specific player - * Must be used when per game config is loaded - */ - @Synchronized - external fun saveControlPlayerValues() -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt deleted file mode 100644 index 331b7ddca..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.app.Activity -import android.app.PendingIntent -import android.content.Intent -import android.content.IntentFilter -import android.nfc.NfcAdapter -import android.nfc.Tag -import android.nfc.tech.NfcA -import android.os.Build -import android.os.Handler -import android.os.Looper -import java.io.IOException -import org.yuzu.yuzu_emu.features.input.NativeInput - -class NfcReader(private val activity: Activity) { - private var nfcAdapter: NfcAdapter? = null - private var pendingIntent: PendingIntent? = null - - fun initialize() { - nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return - - pendingIntent = PendingIntent.getActivity( - activity, - 0, - Intent(activity, activity.javaClass), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - ) - - val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED) - tagDetected.addCategory(Intent.CATEGORY_DEFAULT) - } - - fun startScanning() { - nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null) - } - - fun stopScanning() { - nfcAdapter?.disableForegroundDispatch(activity) - } - - fun onNewIntent(intent: Intent) { - val action = intent.action - if (NfcAdapter.ACTION_TAG_DISCOVERED != action && - NfcAdapter.ACTION_TECH_DISCOVERED != action && - NfcAdapter.ACTION_NDEF_DISCOVERED != action - ) { - return - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val tag = - intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return - readTagData(tag) - return - } - - val tag = - intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return - readTagData(tag) - } - - private fun readTagData(tag: Tag) { - if (!tag.techList.contains("android.nfc.tech.NfcA")) { - return - } - - val amiibo = NfcA.get(tag) ?: return - amiibo.connect() - - val tagData = ntag215ReadAll(amiibo) ?: return - NativeInput.onReadNfcTag(tagData) - - nfcAdapter?.ignore( - tag, - 1000, - { NativeInput.onRemoveNfcTag() }, - Handler(Looper.getMainLooper()) - ) - } - - private fun ntag215ReadAll(amiibo: NfcA): ByteArray? { - val bufferSize = amiibo.maxTransceiveLength - val tagSize = 0x21C - val pageSize = 4 - val lastPage = tagSize / pageSize - 1 - val tagData = ByteArray(tagSize) - - // We need to read the ntag in steps otherwise we overflow the buffer - for (i in 0..tagSize step bufferSize - 1) { - val dataStart = i / pageSize - var dataEnd = (i + bufferSize) / pageSize - - if (dataEnd > lastPage) { - dataEnd = lastPage - } - - try { - val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1) - System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize) - } catch (e: IOException) { - return null - } - } - return tagData - } - - private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? { - return amiibo.transceive( - byteArrayOf( - 0x30.toByte(), - (page and 0xFF).toByte() - ) - ) - } - - private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? { - return amiibo.transceive( - byteArrayOf( - 0x3A.toByte(), - (start and 0xFF).toByte(), - (end and 0xFF).toByte() - ) - ) - } - - private fun ntag215PWrite( - amiibo: NfcA, - page: Int, - data1: Int, - data2: Int, - data3: Int, - data4: Int - ): ByteArray? { - return amiibo.transceive( - byteArrayOf( - 0xA2.toByte(), - (page and 0xFF).toByte(), - (data1 and 0xFF).toByte(), - (data2 and 0xFF).toByte(), - (data3 and 0xFF).toByte(), - (data4 and 0xFF).toByte() - ) - ) - } - - private fun ntag215PwdAuth( - amiibo: NfcA, - data1: Int, - data2: Int, - data3: Int, - data4: Int - ): ByteArray? { - return amiibo.transceive( - byteArrayOf( - 0x1B.toByte(), - (data1 and 0xFF).toByte(), - (data2 and 0xFF).toByte(), - (data3 and 0xFF).toByte(), - (data4 and 0xFF).toByte() - ) - ) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt deleted file mode 100644 index 83fc7da3c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ParamPackage.kt +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -// Kotlin version of src/common/param_package.h -class ParamPackage(serialized: String = "") { - private val KEY_VALUE_SEPARATOR = ":" - private val PARAM_SEPARATOR = "," - - private val ESCAPE_CHARACTER = "$" - private val KEY_VALUE_SEPARATOR_ESCAPE = "$0" - private val PARAM_SEPARATOR_ESCAPE = "$1" - private val ESCAPE_CHARACTER_ESCAPE = "$2" - - private val EMPTY_PLACEHOLDER = "[empty]" - - val data = mutableMapOf() - - init { - val pairs = serialized.split(PARAM_SEPARATOR) - for (pair in pairs) { - val keyValue = pair.split(KEY_VALUE_SEPARATOR).toMutableList() - if (keyValue.size != 2) { - Log.error("[ParamPackage] Invalid key pair $keyValue") - continue - } - - keyValue.forEachIndexed { i: Int, _: String -> - keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR_ESCAPE, KEY_VALUE_SEPARATOR) - keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR_ESCAPE, PARAM_SEPARATOR) - keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER_ESCAPE, ESCAPE_CHARACTER) - } - - set(keyValue[0], keyValue[1]) - } - } - - constructor(params: List>) : this() { - params.forEach { - data[it.first] = it.second - } - } - - fun serialize(): String { - if (data.isEmpty()) { - return EMPTY_PLACEHOLDER - } - - val result = StringBuilder() - data.forEach { - val keyValue = mutableListOf(it.key, it.value) - keyValue.forEachIndexed { i, _ -> - keyValue[i] = keyValue[i].replace(ESCAPE_CHARACTER, ESCAPE_CHARACTER_ESCAPE) - keyValue[i] = keyValue[i].replace(PARAM_SEPARATOR, PARAM_SEPARATOR_ESCAPE) - keyValue[i] = keyValue[i].replace(KEY_VALUE_SEPARATOR, KEY_VALUE_SEPARATOR_ESCAPE) - } - result.append("${keyValue[0]}$KEY_VALUE_SEPARATOR${keyValue[1]}$PARAM_SEPARATOR") - } - return result.removeSuffix(PARAM_SEPARATOR).toString() - } - - fun get(key: String, defaultValue: String): String = - if (has(key)) { - data[key]!! - } else { - Log.debug("[ParamPackage] key $key not found") - defaultValue - } - - fun get(key: String, defaultValue: Int): Int = - if (has(key)) { - try { - data[key]!!.toInt() - } catch (e: NumberFormatException) { - Log.debug("[ParamPackage] failed to convert ${data[key]!!} to int") - defaultValue - } - } else { - Log.debug("[ParamPackage] key $key not found") - defaultValue - } - - private fun Int.toBoolean(): Boolean = - if (this == 1) { - true - } else if (this == 0) { - false - } else { - throw Exception("Tried to convert a value to a boolean that was not 0 or 1!") - } - - fun get(key: String, defaultValue: Boolean): Boolean = - if (has(key)) { - try { - get(key, if (defaultValue) 1 else 0).toBoolean() - } catch (e: Exception) { - Log.debug("[ParamPackage] failed to convert ${data[key]!!} to boolean") - defaultValue - } - } else { - Log.debug("[ParamPackage] key $key not found") - defaultValue - } - - fun get(key: String, defaultValue: Float): Float = - if (has(key)) { - try { - data[key]!!.toFloat() - } catch (e: NumberFormatException) { - Log.debug("[ParamPackage] failed to convert ${data[key]!!} to float") - defaultValue - } - } else { - Log.debug("[ParamPackage] key $key not found") - defaultValue - } - - fun set(key: String, value: String) { - data[key] = value - } - - fun set(key: String, value: Int) { - data[key] = value.toString() - } - - fun Boolean.toInt(): Int = if (this) 1 else 0 - fun set(key: String, value: Boolean) { - data[key] = value.toInt().toString() - } - - fun set(key: String, value: Float) { - data[key] = value.toString() - } - - fun has(key: String): Boolean = data.containsKey(key) - - fun erase(key: String) = data.remove(key) - - fun clear() = data.clear() -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PreferenceUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PreferenceUtil.kt deleted file mode 100644 index a233ba25c..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/PreferenceUtil.kt +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.content.SharedPreferences - -object PreferenceUtil { - /** - * Retrieves a shared preference value and then deletes the value in storage. - * @param key Associated key for the value in this preferences instance - * @return Typed value associated with [key]. Null if no such key exists. - */ - inline fun SharedPreferences.migratePreference(key: String): T? { - if (!this.contains(key)) { - return null - } - - val value: Any = when (T::class) { - String::class -> this.getString(key, "")!! - - Boolean::class -> this.getBoolean(key, false) - - Int::class -> this.getInt(key, 0) - - Float::class -> this.getFloat(key, 0f) - - Long::class -> this.getLong(key, 0) - - else -> throw IllegalStateException("Tried to migrate preference with invalid type!") - } - deletePreference(key) - return value as T - } - - fun SharedPreferences.deletePreference(key: String) = this.edit().remove(key).apply() -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt deleted file mode 100644 index 00e58faec..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt +++ /dev/null @@ -1,44 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.os.Parcelable -import java.io.Serializable - -object SerializableHelper { - inline fun Bundle.serializable(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getSerializable(key, T::class.java) - } else { - getSerializable(key) as? T - } - } - - inline fun Intent.serializable(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getSerializableExtra(key, T::class.java) - } else { - getSerializableExtra(key) as? T - } - } - - inline fun Bundle.parcelable(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) as? T - } - } - - inline fun Intent.parcelable(key: String): T? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableExtra(key, T::class.java) - } else { - getParcelableExtra(key) as? T - } - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt deleted file mode 100644 index 6f7f40e43..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt +++ /dev/null @@ -1,105 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.content.res.Configuration -import android.graphics.Color -import android.os.Build -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 kotlin.math.roundToInt -import org.yuzu.yuzu_emu.R -import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting -import org.yuzu.yuzu_emu.features.settings.model.IntSetting -import org.yuzu.yuzu_emu.ui.main.ThemeProvider - -object ThemeHelper { - const val SYSTEM_BAR_ALPHA = 0.9f - - fun setTheme(activity: AppCompatActivity) { - setThemeMode(activity) - when (Theme.from(IntSetting.THEME.getInt())) { - Theme.Default -> activity.setTheme(R.style.Theme_Yuzu_Main) - Theme.MaterialYou -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou) - } else { - activity.setTheme(R.style.Theme_Yuzu_Main) - } - } - } - - // Using a specific night mode check because this could apply incorrectly when using the - // light app mode, dark system mode, and black backgrounds. Launching the settings activity - // will then show light mode colors/navigation bars but with black backgrounds. - if (BooleanSetting.BLACK_BACKGROUNDS.getBoolean() && isNightMode(activity)) { - activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark) - } - } - - @ColorInt - fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int { - return Color.argb( - (alphaFactor * Color.alpha(color)).roundToInt(), - Color.red(color), - Color.green(color), - Color.blue(color) - ) - } - - fun setCorrectTheme(activity: AppCompatActivity) { - val currentTheme = (activity as ThemeProvider).themeId - setTheme(activity) - if (currentTheme != (activity as ThemeProvider).themeId) { - activity.recreate() - } - } - - fun setThemeMode(activity: AppCompatActivity) { - val themeMode = IntSetting.THEME_MODE.getInt() - 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 - } -} - -enum class Theme(val int: Int) { - Default(0), - MaterialYou(1); - - companion object { - fun from(int: Int): Theme = entries.firstOrNull { it.int == int } ?: Default - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ViewUtils.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ViewUtils.kt deleted file mode 100644 index 244091aec..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ViewUtils.kt +++ /dev/null @@ -1,93 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.utils - -import android.text.TextUtils -import android.view.View -import android.view.ViewGroup -import android.widget.TextView - -object ViewUtils { - fun showView(view: View, length: Long = 300) { - view.apply { - alpha = 0f - visibility = View.VISIBLE - isClickable = true - }.animate().apply { - duration = length - alpha(1f) - }.start() - } - - fun hideView(view: View, length: Long = 300) { - if (view.visibility == View.INVISIBLE) { - return - } - - view.apply { - alpha = 1f - isClickable = false - }.animate().apply { - duration = length - alpha(0f) - }.withEndAction { - view.visibility = View.INVISIBLE - }.start() - } - - fun View.updateMargins( - left: Int = -1, - top: Int = -1, - right: Int = -1, - bottom: Int = -1 - ) { - val layoutParams = this.layoutParams as ViewGroup.MarginLayoutParams - layoutParams.apply { - if (left != -1) { - leftMargin = left - } - if (top != -1) { - topMargin = top - } - if (right != -1) { - rightMargin = right - } - if (bottom != -1) { - bottomMargin = bottom - } - } - this.layoutParams = layoutParams - } - - /** - * Shows or hides a view. - * @param visible Whether a view will be made View.VISIBLE or View.INVISIBLE/GONE. - * @param gone Optional parameter for hiding a view. Uses View.GONE if true and View.INVISIBLE otherwise. - */ - fun View.setVisible(visible: Boolean, gone: Boolean = true) { - visibility = if (visible) { - View.VISIBLE - } else { - if (gone) { - View.GONE - } else { - View.INVISIBLE - } - } - } - - /** - * Starts a marquee on some text. - * @param delay Optional parameter for changing the start delay. 3 seconds of delay by default. - */ - fun TextView.marquee(delay: Long = 3000) { - ellipsize = null - marqueeRepeatLimit = -1 - isSingleLine = true - postDelayed({ - ellipsize = TextUtils.TruncateAt.MARQUEE - isSelected = true - }, delay) - } -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt deleted file mode 100644 index 7101ad434..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/viewholder/AbstractViewHolder.kt +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: 2024 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.viewholder - -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding -import org.yuzu.yuzu_emu.adapters.AbstractDiffAdapter -import org.yuzu.yuzu_emu.adapters.AbstractListAdapter - -/** - * [RecyclerView.ViewHolder] meant to work together with a [AbstractDiffAdapter] or a - * [AbstractListAdapter] so we can run [bind] on each list item without needing a manual hookup. - */ -abstract class AbstractViewHolder(binding: ViewBinding) : - RecyclerView.ViewHolder(binding.root) { - abstract fun bind(model: Model) -} diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt deleted file mode 100644 index 2f0868c63..000000000 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-FileCopyrightText: 2023 yuzu Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -package org.yuzu.yuzu_emu.views - -import android.content.Context -import android.util.AttributeSet -import android.util.Rational -import android.view.SurfaceView - -class FixedRatioSurfaceView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : SurfaceView(context, attrs, defStyleAttr) { - private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch - - /** - * Sets the desired aspect ratio for this view - * @param ratio the ratio to force the view to, or null to stretch to fit - */ - fun setAspectRatio(ratio: Rational?) { - aspectRatio = ratio?.toFloat() ?: 0f - requestLayout() - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - val displayWidth: Float = MeasureSpec.getSize(widthMeasureSpec).toFloat() - val displayHeight: Float = MeasureSpec.getSize(heightMeasureSpec).toFloat() - if (aspectRatio != 0f) { - val displayAspect = displayWidth / displayHeight - if (displayAspect < aspectRatio) { - // Max out width - val halfHeight = displayHeight / 2 - val surfaceHeight = displayWidth / aspectRatio - val newTop: Float = halfHeight - (surfaceHeight / 2) - val newBottom: Float = halfHeight + (surfaceHeight / 2) - super.onMeasure( - widthMeasureSpec, - MeasureSpec.makeMeasureSpec( - newBottom.toInt() - newTop.toInt(), - MeasureSpec.EXACTLY - ) - ) - return - } else { - // Max out height - val halfWidth = displayWidth / 2 - val surfaceWidth = displayHeight * aspectRatio - val newLeft: Float = halfWidth - (surfaceWidth / 2) - val newRight: Float = halfWidth + (surfaceWidth / 2) - super.onMeasure( - MeasureSpec.makeMeasureSpec( - newRight.toInt() - newLeft.toInt(), - MeasureSpec.EXACTLY - ), - heightMeasureSpec - ) - return - } - } - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - } -} diff --git a/src/android/app/src/main/res/drawable/ic_citron.xml b/src/android/app/src/main/res/drawable/ic_citron.xml new file mode 100644 index 000000000..5e2a8efd0 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citron.xml @@ -0,0 +1,22 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_citron_full.xml b/src/android/app/src/main/res/drawable/ic_citron_full.xml new file mode 100644 index 000000000..04e458400 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citron_full.xml @@ -0,0 +1,12 @@ + + + + diff --git a/src/android/app/src/main/res/drawable/ic_citron_title.xml b/src/android/app/src/main/res/drawable/ic_citron_title.xml new file mode 100644 index 000000000..b733e5248 --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_citron_title.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_yuzu.xml b/src/android/app/src/main/res/drawable/ic_yuzu.xml deleted file mode 100644 index 5e2a8efd0..000000000 --- a/src/android/app/src/main/res/drawable/ic_yuzu.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_full.xml b/src/android/app/src/main/res/drawable/ic_yuzu_full.xml deleted file mode 100644 index 04e458400..000000000 --- a/src/android/app/src/main/res/drawable/ic_yuzu_full.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_title.xml b/src/android/app/src/main/res/drawable/ic_yuzu_title.xml deleted file mode 100644 index b733e5248..000000000 --- a/src/android/app/src/main/res/drawable/ic_yuzu_title.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - diff --git a/src/android/app/src/main/res/values-night/citron_colors.xml b/src/android/app/src/main/res/values-night/citron_colors.xml new file mode 100644 index 000000000..49d823324 --- /dev/null +++ b/src/android/app/src/main/res/values-night/citron_colors.xml @@ -0,0 +1,37 @@ + + + + #A7DDEC + #003399 + #31323F + #D1E4FF + #BAC8DB + #253140 + #3B4858 + #D6E4F7 + #D6BEE5 + #3A2948 + #524060 + #F2DAFF + #FFB4AB + #93000A + #690005 + #FFDAD6 + #1A1C1E + #E2E2E6 + #1B1B1D + #E2E2E6 + #26282C + #C3C7CF + #8C9199 + #1A1C1E + #E2E2E6 + #0062A2 + #000000 + #9DCAFF + #42474E + + #840099 + #005AE1 + + diff --git a/src/android/app/src/main/res/values-night/yuzu_colors.xml b/src/android/app/src/main/res/values-night/yuzu_colors.xml deleted file mode 100644 index 49d823324..000000000 --- a/src/android/app/src/main/res/values-night/yuzu_colors.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - #A7DDEC - #003399 - #31323F - #D1E4FF - #BAC8DB - #253140 - #3B4858 - #D6E4F7 - #D6BEE5 - #3A2948 - #524060 - #F2DAFF - #FFB4AB - #93000A - #690005 - #FFDAD6 - #1A1C1E - #E2E2E6 - #1B1B1D - #E2E2E6 - #26282C - #C3C7CF - #8C9199 - #1A1C1E - #E2E2E6 - #0062A2 - #000000 - #9DCAFF - #42474E - - #840099 - #005AE1 - - diff --git a/src/android/app/src/main/res/values/citron_colors.xml b/src/android/app/src/main/res/values/citron_colors.xml new file mode 100644 index 000000000..5b7d189dc --- /dev/null +++ b/src/android/app/src/main/res/values/citron_colors.xml @@ -0,0 +1,37 @@ + + + + #990E00 + #FFFFFF + #EEDEDD + #400200 + #775650 + #FFFFFF + #FFDAD4 + #2C1511 + #6F5C2E + #FFFFFF + #FAE0A6 + #251A00 + #BA1A1A + #FFDAD6 + #FFFFFF + #410002 + #FFFBFF + #201A19 + #FFFBFF + #201A19 + #F5DDD9 + #534340 + #857370 + #FBEEEB + #362F2D + #FFB4A6 + #000000 + #B52612 + #D8C2BE + + #99FFE1 + #76C5FF + + diff --git a/src/android/app/src/main/res/values/yuzu_colors.xml b/src/android/app/src/main/res/values/yuzu_colors.xml deleted file mode 100644 index 5b7d189dc..000000000 --- a/src/android/app/src/main/res/values/yuzu_colors.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - #990E00 - #FFFFFF - #EEDEDD - #400200 - #775650 - #FFFFFF - #FFDAD4 - #2C1511 - #6F5C2E - #FFFFFF - #FAE0A6 - #251A00 - #BA1A1A - #FFDAD6 - #FFFFFF - #410002 - #FFFBFF - #201A19 - #FFFBFF - #201A19 - #F5DDD9 - #534340 - #857370 - #FBEEEB - #362F2D - #FFB4A6 - #000000 - #B52612 - #D8C2BE - - #99FFE1 - #76C5FF - - -- cgit v1.2.3