summaryrefslogtreecommitdiffstats
path: root/src/android/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/android/app/src/main')
-rw-r--r--src/android/app/src/main/AndroidManifest.xml91
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt508
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt333
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt134
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt69
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt54
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt70
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt121
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt100
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt48
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt31
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt101
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt302
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt12
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt8
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt38
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt36
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt131
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt37
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt158
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt10
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt37
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt31
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt13
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt39
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt40
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt64
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt58
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt14
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt62
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt243
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt84
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt57
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt340
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt122
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt465
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt58
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt48
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt30
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt38
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt36
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt60
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt39
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt35
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt48
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt241
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt125
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt83
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt613
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt330
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt210
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt70
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt59
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt137
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt62
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt38
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt30
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt236
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt329
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt86
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt61
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt41
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt109
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt36
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt16
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt19
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt47
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt1064
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt148
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt274
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt282
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt165
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt470
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt11
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt25
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt68
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt37
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt112
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt68
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt330
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt70
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt98
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt152
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt47
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt360
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt31
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt40
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt168
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt40
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt97
-rw-r--r--src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt46
-rw-r--r--src/android/app/src/main/jni/CMakeLists.txt28
-rw-r--r--src/android/app/src/main/jni/android_common/android_common.cpp35
-rw-r--r--src/android/app/src/main/jni/android_common/android_common.h12
-rw-r--r--src/android/app/src/main/jni/applets/software_keyboard.cpp277
-rw-r--r--src/android/app/src/main/jni/applets/software_keyboard.h78
-rw-r--r--src/android/app/src/main/jni/config.cpp297
-rw-r--r--src/android/app/src/main/jni/config.h37
-rw-r--r--src/android/app/src/main/jni/default_ini.h507
-rw-r--r--src/android/app/src/main/jni/emu_window/emu_window.cpp79
-rw-r--r--src/android/app/src/main/jni/emu_window/emu_window.h64
-rw-r--r--src/android/app/src/main/jni/id_cache.cpp116
-rw-r--r--src/android/app/src/main/jni/id_cache.h19
-rw-r--r--src/android/app/src/main/jni/native.cpp774
-rw-r--r--src/android/app/src/main/jni/native.h165
-rw-r--r--src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml16
-rw-r--r--src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml16
-rw-r--r--src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml16
-rw-r--r--src/android/app/src/main/res/anim/anim_settings_fragment_in.xml16
-rw-r--r--src/android/app/src/main/res/anim/anim_settings_fragment_out.xml10
-rw-r--r--src/android/app/src/main/res/animator/menu_slide_in_from_start.xml20
-rw-r--r--src/android/app/src/main/res/animator/menu_slide_out_to_start.xml21
-rw-r--r--src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.pngbin0 -> 46179 bytes
-rw-r--r--src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.pngbin0 -> 48264 bytes
-rw-r--r--src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.pngbin0 -> 56651 bytes
-rw-r--r--src/android/app/src/main/res/drawable/default_icon.jpgbin0 -> 6285 bytes
-rw-r--r--src/android/app/src/main/res/drawable/dpad_standard.xml24
-rw-r--r--src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml24
-rw-r--r--src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml24
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_a.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_a_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_b.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_b_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_home.xml21
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_home_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_minus.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml9
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_plus.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml9
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_screenshot.xml21
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_x.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_x_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_y.xml22
-rw-r--r--src/android/app/src/main/res/drawable/facebutton_y_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/ic_add.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_arrow_forward.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_back.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_cartridge.xml12
-rw-r--r--src/android/app/src/main/res/drawable/ic_cartridge_outline.xml12
-rw-r--r--src/android/app/src/main/res/drawable/ic_check.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_check_circle.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_clear.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_controller.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_diamond.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_discord.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_exit.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_firmware.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_folder_open.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_github.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_icon_bg.xml751
-rw-r--r--src/android/app/src/main/res/drawable/ic_info_outline.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_install.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_key.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_launcher.xml6
-rw-r--r--src/android/app/src/main/res/drawable/ic_log.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_nfc.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_notification.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_options.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_palette.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_pause.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_play.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_save.xml10
-rw-r--r--src/android/app/src/main/res/drawable/ic_search.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_settings.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_settings_outline.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_unlock.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_website.xml9
-rw-r--r--src/android/app/src/main/res/drawable/ic_yuzu.xml22
-rw-r--r--src/android/app/src/main/res/drawable/ic_yuzu_full.xml12
-rw-r--r--src/android/app/src/main/res/drawable/ic_yuzu_title.xml24
-rw-r--r--src/android/app/src/main/res/drawable/joystick.xml45
-rw-r--r--src/android/app/src/main/res/drawable/joystick_depressed.xml10
-rw-r--r--src/android/app/src/main/res/drawable/joystick_range.xml38
-rw-r--r--src/android/app/src/main/res/drawable/l_shoulder.xml23
-rw-r--r--src/android/app/src/main/res/drawable/l_shoulder_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/premium_background.xml9
-rw-r--r--src/android/app/src/main/res/drawable/r_shoulder.xml23
-rw-r--r--src/android/app/src/main/res/drawable/r_shoulder_depressed.xml8
-rw-r--r--src/android/app/src/main/res/drawable/selector_cartridge.xml5
-rw-r--r--src/android/app/src/main/res/drawable/selector_settings.xml5
-rw-r--r--src/android/app/src/main/res/drawable/zl_trigger.xml25
-rw-r--r--src/android/app/src/main/res/drawable/zl_trigger_depressed.xml10
-rw-r--r--src/android/app/src/main/res/drawable/zr_trigger.xml25
-rw-r--r--src/android/app/src/main/res/drawable/zr_trigger_depressed.xml10
-rw-r--r--src/android/app/src/main/res/layout-w600dp/activity_main.xml58
-rw-r--r--src/android/app/src/main/res/layout-w600dp/fragment_setup.xml40
-rw-r--r--src/android/app/src/main/res/layout-w600dp/page_setup.xml65
-rw-r--r--src/android/app/src/main/res/layout/activity_emulation.xml13
-rw-r--r--src/android/app/src/main/res/layout/activity_main.xml58
-rw-r--r--src/android/app/src/main/res/layout/activity_settings.xml50
-rw-r--r--src/android/app/src/main/res/layout/card_game.xml67
-rw-r--r--src/android/app/src/main/res/layout/card_home_option.xml60
-rw-r--r--src/android/app/src/main/res/layout/dialog_edit_text.xml23
-rw-r--r--src/android/app/src/main/res/layout/dialog_license.xml64
-rw-r--r--src/android/app/src/main/res/layout/dialog_overlay_adjust.xml67
-rw-r--r--src/android/app/src/main/res/layout/dialog_progress_bar.xml24
-rw-r--r--src/android/app/src/main/res/layout/dialog_slider.xml37
-rw-r--r--src/android/app/src/main/res/layout/fragment_about.xml232
-rw-r--r--src/android/app/src/main/res/layout/fragment_early_access.xml242
-rw-r--r--src/android/app/src/main/res/layout/fragment_emulation.xml70
-rw-r--r--src/android/app/src/main/res/layout/fragment_games.xml34
-rw-r--r--src/android/app/src/main/res/layout/fragment_home_settings.xml34
-rw-r--r--src/android/app/src/main/res/layout/fragment_licenses.xml30
-rw-r--r--src/android/app/src/main/res/layout/fragment_search.xml183
-rw-r--r--src/android/app/src/main/res/layout/fragment_settings.xml14
-rw-r--r--src/android/app/src/main/res/layout/fragment_setup.xml42
-rw-r--r--src/android/app/src/main/res/layout/header_in_game.xml14
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting.xml41
-rw-r--r--src/android/app/src/main/res/layout/list_item_setting_switch.xml50
-rw-r--r--src/android/app/src/main/res/layout/list_item_settings_header.xml20
-rw-r--r--src/android/app/src/main/res/layout/page_setup.xml72
-rw-r--r--src/android/app/src/main/res/menu-w600dp/menu_navigation.xml19
-rw-r--r--src/android/app/src/main/res/menu/menu_in_game.xml24
-rw-r--r--src/android/app/src/main/res/menu/menu_navigation.xml19
-rw-r--r--src/android/app/src/main/res/menu/menu_overlay_options.xml45
-rw-r--r--src/android/app/src/main/res/menu/menu_settings.xml2
-rw-r--r--src/android/app/src/main/res/navigation/home_navigation.xml59
-rw-r--r--src/android/app/src/main/res/values-night-v31/themes.xml31
-rw-r--r--src/android/app/src/main/res/values-night/themes.xml9
-rw-r--r--src/android/app/src/main/res/values-night/yuzu_colors.xml37
-rw-r--r--src/android/app/src/main/res/values-v31/themes.xml31
-rw-r--r--src/android/app/src/main/res/values-w600dp/bools.xml4
-rw-r--r--src/android/app/src/main/res/values-w600dp/dimens.xml5
-rw-r--r--src/android/app/src/main/res/values/arrays.xml227
-rw-r--r--src/android/app/src/main/res/values/bools.xml4
-rw-r--r--src/android/app/src/main/res/values/dimens.xml18
-rw-r--r--src/android/app/src/main/res/values/integers.xml37
-rw-r--r--src/android/app/src/main/res/values/strings.xml866
-rw-r--r--src/android/app/src/main/res/values/styles.xml36
-rw-r--r--src/android/app/src/main/res/values/themes.xml51
-rw-r--r--src/android/app/src/main/res/values/yuzu_colors.xml37
-rw-r--r--src/android/app/src/main/res/xml/data_extraction_rules.xml20
-rw-r--r--src/android/app/src/main/res/xml/data_extraction_rules_api_31.xml43
-rw-r--r--src/android/app/src/main/res/xml/nfc_tech_filter.xml6
242 files changed, 19356 insertions, 0 deletions
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..43087f2c0
--- /dev/null
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+ <uses-feature
+ android:name="android.hardware.touchscreen"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.gamepad"
+ android:required="false"/>
+
+ <uses-feature
+ android:name="android.hardware.vulkan.version"
+ android:version="0x401000"
+ android:required="true" />
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.NFC" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+ <application
+ android:name="org.yuzu.yuzu_emu.YuzuApplication"
+ android:label="@string/app_name"
+ android:icon="@drawable/ic_launcher"
+ android:allowBackup="true"
+ android:hasFragileUserData="true"
+ android:supportsRtl="true"
+ android:isGame="true"
+ android:banner="@drawable/ic_launcher"
+ android:extractNativeLibs="true"
+ android:fullBackupContent="@xml/data_extraction_rules"
+ android:dataExtractionRules="@xml/data_extraction_rules_api_31"
+ android:enableOnBackInvokedCallback="true">
+
+ <activity
+ android:name="org.yuzu.yuzu_emu.ui.main.MainActivity"
+ android:exported="true"
+ android:theme="@style/Theme.Yuzu.Splash.Main">
+
+ <!-- This intentfilter marks this Activity as the one that gets launched from Home screen. -->
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name="org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity"
+ android:theme="@style/Theme.Yuzu.Main"
+ android:label="@string/preferences_settings"/>
+
+ <activity
+ android:name="org.yuzu.yuzu_emu.activities.EmulationActivity"
+ android:theme="@style/Theme.Yuzu.Main"
+ android:launchMode="singleTop"
+ android:screenOrientation="userLandscape"
+ android:exported="true">
+
+ <intent-filter>
+ <action android:name="android.nfc.action.TECH_DISCOVERED" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="application/octet-stream" />
+ </intent-filter>
+
+ <meta-data
+ android:name="android.nfc.action.TECH_DISCOVERED"
+ android:resource="@xml/nfc_tech_filter" />
+ </activity>
+
+ <service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/>
+
+ <provider
+ android:name=".features.DocumentProvider"
+ android:authorities="${applicationId}.user"
+ android:grantUriPermissions="true"
+ android:exported="true"
+ android:permission="android.permission.MANAGE_DOCUMENTS">
+ <intent-filter>
+ <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
+ </intent-filter>
+ </provider>
+
+ </application>
+
+</manifest>
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
new file mode 100644
index 000000000..c11b6bc16
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -0,0 +1,508 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.Surface
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.Keep
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath
+import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize
+import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri
+import org.yuzu.yuzu_emu.utils.Log.error
+import org.yuzu.yuzu_emu.utils.Log.verbose
+import org.yuzu.yuzu_emu.utils.Log.warning
+import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
+import java.lang.ref.WeakReference
+
+/**
+ * Class which contains methods that interact
+ * with the native side of the Yuzu code.
+ */
+object NativeLibrary {
+ /**
+ * Default controller id for each device
+ */
+ const val Player1Device = 0
+ const val Player2Device = 1
+ const val Player3Device = 2
+ const val Player4Device = 3
+ const val Player5Device = 4
+ const val Player6Device = 5
+ const val Player7Device = 6
+ const val Player8Device = 7
+ const val ConsoleDevice = 8
+
+ /**
+ * Controller type for each device
+ */
+ const val ProController = 3
+ const val Handheld = 4
+ const val JoyconDual = 5
+ const val JoyconLeft = 6
+ const val JoyconRight = 7
+ const val GameCube = 8
+ const val Pokeball = 9
+ const val NES = 10
+ const val SNES = 11
+ const val N64 = 12
+ const val SegaGenesis = 13
+
+ @JvmField
+ var sEmulationActivity = WeakReference<EmulationActivity?>(null)
+
+ init {
+ try {
+ System.loadLibrary("yuzu-android")
+ } catch (ex: UnsatisfiedLinkError) {
+ error("[NativeLibrary] $ex")
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ fun openContentUri(path: String?, openmode: String?): Int {
+ return if (isNativePath(path!!)) {
+ YuzuApplication.documentsTree!!.openContentUri(path, openmode)
+ } else openContentUri(appContext, path, openmode)
+ }
+
+ @Keep
+ @JvmStatic
+ fun getSize(path: String?): Long {
+ return if (isNativePath(path!!)) {
+ YuzuApplication.documentsTree!!.getFileSize(path)
+ } else getFileSize(appContext, path)
+ }
+
+ /**
+ * Returns true if pro controller isn't available and handheld is
+ */
+ external fun isHandheldOnly(): Boolean
+
+ /**
+ * Changes controller type for a specific device.
+ *
+ * @param Device The input descriptor of the gamepad.
+ * @param Type The NpadStyleIndex of the gamepad.
+ */
+ external fun setDeviceType(Device: Int, Type: Int): Boolean
+
+ /**
+ * Handles event when a gamepad is connected.
+ *
+ * @param Device The input descriptor of the gamepad.
+ */
+ external fun onGamePadConnectEvent(Device: Int): Boolean
+
+ /**
+ * Handles event when a gamepad is disconnected.
+ *
+ * @param Device The input descriptor of the gamepad.
+ */
+ external fun onGamePadDisconnectEvent(Device: Int): Boolean
+
+ /**
+ * Handles button press events for a gamepad.
+ *
+ * @param Device The input descriptor of the gamepad.
+ * @param Button Key code identifying which button was pressed.
+ * @param Action Mask identifying which action is happening (button pressed down, or button released).
+ * @return If we handled the button press.
+ */
+ external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean
+
+ /**
+ * Handles joystick movement events.
+ *
+ * @param Device The device ID of the gamepad.
+ * @param Axis The axis ID
+ * @param x_axis The value of the x-axis represented by the given ID.
+ * @param y_axis The value of the y-axis represented by the given ID.
+ */
+ external fun onGamePadJoystickEvent(
+ Device: Int,
+ Axis: Int,
+ x_axis: Float,
+ y_axis: Float
+ ): Boolean
+
+ /**
+ * Handles motion events.
+ *
+ * @param delta_timestamp The finger id corresponding to this event
+ * @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor.
+ * @param accel_x,accel_y,accel_z The value of the y-axis
+ */
+ external fun onGamePadMotionEvent(
+ Device: Int,
+ delta_timestamp: Long,
+ gyro_x: Float,
+ gyro_y: Float,
+ gyro_z: Float,
+ accel_x: Float,
+ accel_y: Float,
+ accel_z: Float
+ ): Boolean
+
+ /**
+ * Signals and load a nfc tag
+ *
+ * @param data Byte array containing all the data from a nfc tag
+ */
+ external fun onReadNfcTag(data: ByteArray?): Boolean
+
+ /**
+ * Removes current loaded nfc tag
+ */
+ external fun onRemoveNfcTag(): Boolean
+
+ /**
+ * Handles touch press events.
+ *
+ * @param finger_id The finger id corresponding to this event
+ * @param x_axis The value of the x-axis.
+ * @param y_axis The value of the y-axis.
+ */
+ external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float)
+
+ /**
+ * Handles touch movement.
+ *
+ * @param x_axis The value of the instantaneous x-axis.
+ * @param y_axis The value of the instantaneous y-axis.
+ */
+ external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float)
+
+ /**
+ * Handles touch release events.
+ *
+ * @param finger_id The finger id corresponding to this event
+ */
+ external fun onTouchReleased(finger_id: Int)
+
+ external fun reloadSettings()
+
+ external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String?
+
+ external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?)
+
+ external fun initGameIni(gameID: String?)
+
+ /**
+ * Gets the embedded icon within the given ROM.
+ *
+ * @param filename the file path to the ROM.
+ * @return a byte array containing the JPEG data for the icon.
+ */
+ external fun getIcon(filename: String): ByteArray
+
+ /**
+ * Gets the embedded title of the given ISO/ROM.
+ *
+ * @param filename The file path to the ISO/ROM.
+ * @return the embedded title of the ISO/ROM.
+ */
+ external fun getTitle(filename: String): String
+
+ external fun getDescription(filename: String): String
+
+ external fun getGameId(filename: String): String
+
+ external fun getRegions(filename: String): String
+
+ external fun getCompany(filename: String): String
+
+ external fun setAppDirectory(directory: String)
+
+ external fun initializeGpuDriver(
+ hookLibDir: String?,
+ customDriverDir: String?,
+ customDriverName: String?,
+ fileRedirectDir: String?
+ )
+
+ external fun reloadKeys(): Boolean
+
+ external fun initializeEmulation()
+
+ external fun defaultCPUCore(): Int
+
+ /**
+ * Begins emulation.
+ */
+ external fun run(path: String?)
+
+ /**
+ * Begins emulation from the specified savestate.
+ */
+ external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
+
+ // Surface Handling
+ external fun surfaceChanged(surf: Surface?)
+
+ external fun surfaceDestroyed()
+
+ /**
+ * Unpauses emulation from a paused state.
+ */
+ external fun unPauseEmulation()
+
+ /**
+ * Pauses emulation.
+ */
+ external fun pauseEmulation()
+
+ /**
+ * Stops emulation.
+ */
+ external fun stopEmulation()
+
+ /**
+ * Resets the in-memory ROM metadata cache.
+ */
+ external fun resetRomMetadata()
+
+ /**
+ * Returns true if emulation is running (or is paused).
+ */
+ external fun isRunning(): Boolean
+
+ /**
+ * Returns the performance stats for the current game
+ */
+ external fun getPerfStats(): DoubleArray
+
+ /**
+ * Notifies the core emulation that the orientation has changed.
+ */
+ external fun notifyOrientationChange(layout_option: Int, rotation: Int)
+
+ enum class CoreError {
+ ErrorSystemFiles,
+ ErrorSavestate,
+ ErrorUnknown
+ }
+
+ private var coreErrorAlertResult = false
+ private val coreErrorAlertLock = Object()
+
+ class CoreErrorDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val title = requireArguments().serializable<String>("title")
+ val message = requireArguments().serializable<String>("message")
+
+ return MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.continue_button, null)
+ .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
+ coreErrorAlertResult = false
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
+ }
+ .create()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ coreErrorAlertResult = true
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
+ }
+
+ companion object {
+ fun newInstance(title: String?, message: String?): CoreErrorDialogFragment {
+ val frag = CoreErrorDialogFragment()
+ val args = Bundle()
+ args.putString("title", title)
+ args.putString("message", message)
+ frag.arguments = args
+ return frag
+ }
+ }
+ }
+
+ private fun onCoreErrorImpl(title: String, message: String) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ error("[NativeLibrary] EmulationActivity not present")
+ return
+ }
+
+ val fragment = CoreErrorDialogFragment.newInstance(title, message)
+ fragment.show(emulationActivity.supportFragmentManager, "coreError")
+ }
+
+ /**
+ * Handles a core error.
+ *
+ * @return true: continue; false: abort
+ */
+ fun onCoreError(error: CoreError?, details: String): Boolean {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ error("[NativeLibrary] EmulationActivity not present")
+ return false
+ }
+
+ val title: String
+ val message: String
+ when (error) {
+ CoreError.ErrorSystemFiles -> {
+ title = emulationActivity.getString(R.string.system_archive_not_found)
+ message = emulationActivity.getString(
+ R.string.system_archive_not_found_message,
+ details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) }
+ )
+ }
+ CoreError.ErrorSavestate -> {
+ title = emulationActivity.getString(R.string.save_load_error)
+ message = details
+ }
+ CoreError.ErrorUnknown -> {
+ title = emulationActivity.getString(R.string.fatal_error)
+ message = emulationActivity.getString(R.string.fatal_error_message)
+ }
+ else -> {
+ return true
+ }
+ }
+
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) })
+
+ // Wait for the lock to notify that it is complete.
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() }
+
+ return coreErrorAlertResult
+ }
+
+ @Keep
+ @JvmStatic
+ fun exitEmulationActivity(resultCode: Int) {
+ val Success = 0
+ val ErrorNotInitialized = 1
+ val ErrorGetLoader = 2
+ val ErrorSystemFiles = 3
+ val ErrorSharedFont = 4
+ val ErrorVideoCore = 5
+ val ErrorUnknown = 6
+ val ErrorLoader = 7
+
+ val captionId: Int
+ var descriptionId: Int
+ when (resultCode) {
+ ErrorVideoCore -> {
+ captionId = R.string.loader_error_video_core
+ descriptionId = R.string.loader_error_video_core_description
+ }
+ else -> {
+ captionId = R.string.loader_error_encrypted
+ descriptionId = R.string.loader_error_encrypted_roms_description
+ if (!reloadKeys()) {
+ descriptionId = R.string.loader_error_encrypted_keys_description
+ }
+ }
+ }
+
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ warning("[NativeLibrary] EmulationActivity is null, can't exit.")
+ return
+ }
+
+ val builder = MaterialAlertDialogBuilder(emulationActivity)
+ .setTitle(captionId)
+ .setMessage(
+ Html.fromHtml(
+ emulationActivity.getString(descriptionId),
+ Html.FROM_HTML_MODE_LEGACY
+ )
+ )
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() }
+ .setOnDismissListener { emulationActivity.finish() }
+ emulationActivity.runOnUiThread {
+ val alert = builder.create()
+ alert.show()
+ (alert.findViewById<View>(android.R.id.message) as TextView).movementMethod =
+ LinkMovementMethod.getInstance()
+ }
+ }
+
+ fun setEmulationActivity(emulationActivity: EmulationActivity?) {
+ verbose("[NativeLibrary] Registering EmulationActivity.")
+ sEmulationActivity = WeakReference(emulationActivity)
+ }
+
+ fun clearEmulationActivity() {
+ verbose("[NativeLibrary] Unregistering EmulationActivity.")
+ sEmulationActivity.clear()
+ }
+
+ /**
+ * Logs the Yuzu version, Android version and, CPU.
+ */
+ external fun logDeviceInfo()
+
+ /**
+ * Submits inline keyboard text. Called on input for buttons that result text.
+ * @param text Text to submit to the inline software keyboard implementation.
+ */
+ external fun submitInlineKeyboardText(text: String?)
+
+ /**
+ * Submits inline keyboard input. Used to indicate keys pressed that are not text.
+ * @param key_code Android Key Code associated with the keyboard input.
+ */
+ external fun submitInlineKeyboardInput(key_code: Int)
+
+ /**
+ * Button type for use in onTouchEvent
+ */
+ object ButtonType {
+ const val BUTTON_A = 0
+ const val BUTTON_B = 1
+ const val BUTTON_X = 2
+ const val BUTTON_Y = 3
+ const val STICK_L = 4
+ const val STICK_R = 5
+ const val TRIGGER_L = 6
+ const val TRIGGER_R = 7
+ const val TRIGGER_ZL = 8
+ const val TRIGGER_ZR = 9
+ const val BUTTON_PLUS = 10
+ const val BUTTON_MINUS = 11
+ const val DPAD_LEFT = 12
+ const val DPAD_UP = 13
+ const val DPAD_RIGHT = 14
+ const val DPAD_DOWN = 15
+ const val BUTTON_SL = 16
+ const val BUTTON_SR = 17
+ const val BUTTON_HOME = 18
+ const val BUTTON_CAPTURE = 19
+ }
+
+ /**
+ * Stick type for use in onTouchEvent
+ */
+ object StickType {
+ const val STICK_L = 0
+ const val STICK_R = 1
+ }
+
+ /**
+ * Button states
+ */
+ object ButtonState {
+ const val RELEASED = 0
+ const val PRESSED = 1
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
new file mode 100644
index 000000000..4c947b786
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.DocumentsTree
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import java.io.File
+
+fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir
+
+class YuzuApplication : Application() {
+ private fun createNotificationChannels() {
+ val emulationChannel = NotificationChannel(
+ getString(R.string.emulation_notification_channel_id),
+ getString(R.string.emulation_notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ emulationChannel.description = getString(R.string.emulation_notification_channel_description)
+ emulationChannel.setSound(null, null)
+ emulationChannel.vibrationPattern = null
+
+ val noticeChannel = NotificationChannel(
+ getString(R.string.notice_notification_channel_id),
+ getString(R.string.notice_notification_channel_name),
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ noticeChannel.description = getString(R.string.notice_notification_channel_description)
+ noticeChannel.setSound(null, null)
+
+ // Register the channel with the system; you can't change the importance
+ // or other notification behaviors after this
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(emulationChannel)
+ notificationManager.createNotificationChannel(noticeChannel)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ application = this
+ documentsTree = DocumentsTree()
+ DirectoryInitialization.start(applicationContext)
+ GpuDriverHelper.initializeDriverParameters(applicationContext)
+ NativeLibrary.logDeviceInfo()
+
+ createNotificationChannels();
+ }
+
+ companion object {
+ var documentsTree: DocumentsTree? = null
+ lateinit var application: YuzuApplication
+
+ val appContext: Context
+ get() = application.applicationContext
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
new file mode 100644
index 000000000..94d5156cf
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
@@ -0,0 +1,333 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.activities
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.hardware.display.DisplayManager
+import android.os.Bundle
+import android.view.Display
+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 androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.getSystemService
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.window.layout.WindowInfoTracker
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.fragments.EmulationFragment
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.ControllerMappingHelper
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import org.yuzu.yuzu_emu.utils.ForegroundService
+import org.yuzu.yuzu_emu.utils.InputHandler
+import org.yuzu.yuzu_emu.utils.NfcReader
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+import org.yuzu.yuzu_emu.utils.ThemeHelper
+import kotlin.math.roundToInt
+
+class EmulationActivity : AppCompatActivity(), SensorEventListener {
+ private var controllerMappingHelper: ControllerMappingHelper? = null
+
+ var isActivityRecreated = false
+ private var emulationFragment: EmulationFragment? = null
+ private lateinit var nfcReader: NfcReader
+ private lateinit var inputHandler: InputHandler
+
+ private val gyro = FloatArray(3)
+ private val accel = FloatArray(3)
+ private var motionTimestamp: Long = 0
+ private var flipMotionOrientation: Boolean = false
+
+ private lateinit var game: Game
+
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override fun onDestroy() {
+ stopForegroundService(this)
+ super.onDestroy()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+
+ settingsViewModel.settings.loadSettings()
+
+ super.onCreate(savedInstanceState)
+ if (savedInstanceState == null) {
+ // Get params we were passed
+ game = intent.parcelable(EXTRA_SELECTED_GAME)!!
+ isActivityRecreated = false
+ } else {
+ isActivityRecreated = true
+ restoreState(savedInstanceState)
+ }
+ controllerMappingHelper = ControllerMappingHelper()
+
+ // Set these options now so that the SurfaceView the game renders into is the right size.
+ enableFullscreenImmersive()
+
+ setContentView(R.layout.activity_emulation)
+ window.decorView.setBackgroundColor(getColor(android.R.color.black))
+
+ // Find or create the EmulationFragment
+ emulationFragment =
+ supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment?
+ if (emulationFragment == null) {
+ emulationFragment = EmulationFragment.newInstance(game)
+ supportFragmentManager.beginTransaction()
+ .add(R.id.frame_emulation_fragment, emulationFragment!!)
+ .commit()
+ }
+ title = game.title
+
+ nfcReader = NfcReader(this)
+ nfcReader.initialize()
+
+ inputHandler = InputHandler()
+ inputHandler.initialize()
+
+ lifecycleScope.launch(Dispatchers.Main) {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ WindowInfoTracker.getOrCreate(this@EmulationActivity)
+ .windowLayoutInfo(this@EmulationActivity)
+ .collect { emulationFragment?.updateCurrentLayout(this@EmulationActivity, it) }
+ }
+ }
+
+ // Start a foreground service to prevent the app from getting killed in the background
+ val startIntent = Intent(this, ForegroundService::class.java)
+ startForegroundService(startIntent)
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ // Special case, we do not support multiline input, dismiss the keyboard.
+ val overlayView: View =
+ this.findViewById(R.id.surface_input_overlay)
+ val im =
+ overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ im.hideSoftInputFromWindow(overlayView.windowToken, 0)
+ } else {
+ val textChar = event.unicodeChar
+ if (textChar == 0) {
+ // No text, button input.
+ NativeLibrary.submitInlineKeyboardInput(keyCode)
+ } else {
+ // Text submitted.
+ NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
+ }
+ }
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ nfcReader.startScanning()
+ startMotionSensorListener()
+
+ NativeLibrary.notifyOrientationChange(
+ EmulationMenuSettings.landscapeScreenLayout,
+ getAdjustedRotation()
+ )
+ }
+
+ override fun onPause() {
+ super.onPause()
+ nfcReader.stopScanning()
+ stopMotionSensorListener()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ nfcReader.onNewIntent(intent)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putParcelable(EXTRA_SELECTED_GAME, game)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchKeyEvent(event)
+ }
+
+ return inputHandler.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ // Don't attempt to do anything if we are disconnecting a device.
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ return true
+ }
+
+ return inputHandler.dispatchGenericMotionEvent(event)
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ val rotation = this.display?.rotation
+ if (rotation == Surface.ROTATION_90) {
+ flipMotionOrientation = true
+ }
+ if (rotation == Surface.ROTATION_270) {
+ flipMotionOrientation = false
+ }
+
+ if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
+ if (flipMotionOrientation) {
+ accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
+ } else {
+ accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
+ }
+ accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
+ }
+ if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
+ // Investigate why sensor value is off by 6x
+ if (flipMotionOrientation) {
+ gyro[0] = -event.values[1] / 6.0f
+ gyro[1] = event.values[0] / 6.0f
+ } else {
+ gyro[0] = event.values[1] / 6.0f
+ gyro[1] = -event.values[0] / 6.0f
+ }
+ gyro[2] = event.values[2] / 6.0f
+ }
+
+ // Only update state on accelerometer data
+ if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
+ return
+ }
+ val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
+ motionTimestamp = event.timestamp
+ NativeLibrary.onGamePadMotionEvent(
+ NativeLibrary.Player1Device,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ NativeLibrary.onGamePadMotionEvent(
+ NativeLibrary.ConsoleDevice,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
+
+ private fun getAdjustedRotation():Int {
+ val rotation = getSystemService<DisplayManager>()!!.getDisplay(Display.DEFAULT_DISPLAY).rotation
+ val config: Configuration = resources.configuration
+
+ if ((config.screenLayout and Configuration.SCREENLAYOUT_LONG_YES) != 0 ||
+ (config.screenLayout and Configuration.SCREENLAYOUT_LONG_NO) == 0) {
+ return rotation
+ }
+ when (rotation) {
+ Surface.ROTATION_0 -> return Surface.ROTATION_90
+ Surface.ROTATION_90 -> return Surface.ROTATION_0
+ Surface.ROTATION_180 -> return Surface.ROTATION_270
+ Surface.ROTATION_270 -> return Surface.ROTATION_180
+ }
+ return rotation
+ }
+
+ private fun restoreState(savedInstanceState: Bundle) {
+ game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!!
+ }
+
+ 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 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)
+ }
+
+ fun stopForegroundService(activity: Activity) {
+ val startIntent = Intent(activity, ForegroundService::class.java)
+ startIntent.action = ForegroundService.ACTION_STOP
+ activity.startForegroundService(startIntent)
+ }
+
+ private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
+ if (view == null) {
+ return true
+ }
+ val viewBounds = Rect()
+ view.getGlobalVisibleRect(viewBounds)
+ return !viewBounds.contains(x.roundToInt(), y.roundToInt())
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
new file mode 100644
index 000000000..7f9e2e2d4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.databinding.CardGameBinding
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
+import org.yuzu.yuzu_emu.model.GamesViewModel
+
+class GameAdapter(private val activity: AppCompatActivity) :
+ ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
+ // Create a new view.
+ val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.cardGame.setOnClickListener(this)
+
+ // Use that view to create a ViewHolder.
+ return GameViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ override fun getItemCount(): Int = currentList.size
+
+ /**
+ * Launches the game that was clicked on.
+ *
+ * @param view The card representing the game the user wants to play.
+ */
+ override fun onClick(view: View) {
+ val holder = view.tag as GameViewHolder
+
+ val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true
+ if (!gameExists) {
+ Toast.makeText(
+ YuzuApplication.appContext,
+ R.string.loader_error_file_not_found,
+ Toast.LENGTH_LONG
+ ).show()
+
+ ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+ return
+ }
+
+ val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ preferences.edit()
+ .putLong(
+ holder.game.keyLastPlayedTime,
+ System.currentTimeMillis()
+ )
+ .apply()
+
+ EmulationActivity.launch(activity, holder.game)
+ }
+
+ inner class GameViewHolder(val binding: CardGameBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var game: Game
+
+ init {
+ binding.cardGame.tag = this
+ }
+
+ fun bind(game: Game) {
+ this.game = game
+
+ binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
+ activity.lifecycleScope.launch {
+ val bitmap = decodeGameIcon(game.path)
+ binding.imageGameScreen.load(bitmap) {
+ error(R.drawable.default_icon)
+ }
+ }
+
+ binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
+
+ binding.textGameTitle.postDelayed(
+ {
+ binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.textGameTitle.isSelected = true
+ },
+ 3000
+ )
+ }
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback<Game>() {
+ override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem.gameId == newItem.gameId
+ }
+
+ override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ private fun decodeGameIcon(uri: String): Bitmap? {
+ val data = NativeLibrary.getIcon(uri)
+ return BitmapFactory.decodeByteArray(
+ data,
+ 0,
+ data.size,
+ BitmapFactory.Options()
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
new file mode 100644
index 000000000..b719dd539
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
+import org.yuzu.yuzu_emu.model.HomeSetting
+
+class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List<HomeSetting>) :
+ RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
+ val binding =
+ CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return HomeOptionViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int {
+ return options.size
+ }
+
+ override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
+ holder.bind(options[position])
+ }
+
+ override fun onClick(view: View) {
+ val holder = view.tag as HomeOptionViewHolder
+ holder.option.onClick.invoke()
+ }
+
+ inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var option: HomeSetting
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(option: HomeSetting) {
+ this.option = option
+ binding.optionTitle.text = activity.resources.getString(option.titleId)
+ binding.optionDescription.text = activity.resources.getString(option.descriptionId)
+ binding.optionIcon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ option.iconId,
+ activity.theme
+ )
+ )
+
+ when (option.titleId) {
+ R.string.get_early_access -> binding.optionLayout.background =
+ ContextCompat.getDrawable(
+ binding.optionCard.context,
+ R.drawable.premium_background
+ )
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
new file mode 100644
index 000000000..7006651d0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment
+import org.yuzu.yuzu_emu.model.License
+
+class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List<License>) :
+ RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
+ val binding =
+ ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return LicenseViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = licenses.size
+
+ override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) {
+ holder.bind(licenses[position])
+ }
+
+ override fun onClick(view: View) {
+ val license = (view.tag as LicenseViewHolder).license
+ LicenseBottomSheetDialogFragment.newInstance(license)
+ .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
+ }
+
+ inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) {
+ lateinit var license: License
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(license: License) {
+ this.license = license
+
+ val context = YuzuApplication.appContext
+ binding.textSettingName.text = context.getString(license.titleId)
+ binding.textSettingDescription.text = context.getString(license.descriptionId)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
new file mode 100644
index 000000000..481ddd5a5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.text.Html
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import org.yuzu.yuzu_emu.databinding.PageSetupBinding
+import org.yuzu.yuzu_emu.model.SetupPage
+
+class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) :
+ RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
+ val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return SetupPageViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = pages.size
+
+ override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
+ holder.bind(pages[position])
+
+ inner class SetupPageViewHolder(val binding: PageSetupBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var page: SetupPage
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(page: SetupPage) {
+ this.page = page
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ page.iconId,
+ activity.theme
+ )
+ )
+ binding.textTitle.text = activity.resources.getString(page.titleId)
+ binding.textDescription.text =
+ Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
+
+ binding.buttonAction.apply {
+ text = activity.resources.getString(page.buttonTextId)
+ if (page.buttonIconId != 0) {
+ icon = ResourcesCompat.getDrawable(
+ activity.resources,
+ page.buttonIconId,
+ activity.theme
+ )
+ }
+ iconGravity =
+ if (page.leftAlignedIcon) {
+ MaterialButton.ICON_GRAVITY_START
+ } else {
+ MaterialButton.ICON_GRAVITY_END
+ }
+ setOnClickListener {
+ page.buttonAction.invoke()
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt
new file mode 100644
index 000000000..82a6712b6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt
@@ -0,0 +1,121 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.applets.keyboard
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.view.KeyEvent
+import android.view.View
+import android.view.WindowInsets
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.Keep
+import androidx.core.view.ViewCompat
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment
+import java.io.Serializable
+
+@Keep
+object SoftwareKeyboard {
+ lateinit var data: KeyboardData
+ val dataLock = Object()
+
+ private fun executeNormalImpl(config: KeyboardConfig) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+ data = KeyboardData(SwkbdResult.Cancel.ordinal, "")
+ val fragment = KeyboardDialogFragment.newInstance(config)
+ fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG)
+ }
+
+ private fun executeInlineImpl(config: KeyboardConfig) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+
+ val overlayView = emulationActivity!!.findViewById<View>(R.id.surface_input_overlay)
+ val im =
+ overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED)
+
+ // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result.
+ val handler = Handler(Looper.myLooper()!!)
+ val delayMs = 500
+ handler.postDelayed(object : Runnable {
+ override fun run() {
+ val insets = ViewCompat.getRootWindowInsets(overlayView)
+ val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime())
+ if (isKeyboardVisible) {
+ handler.postDelayed(this, delayMs.toLong())
+ return
+ }
+
+ // No longer visible, submit the result.
+ NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER)
+ }
+ }, delayMs.toLong())
+ }
+
+ @JvmStatic
+ fun executeNormal(config: KeyboardConfig): KeyboardData {
+ NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) }
+ synchronized(dataLock) {
+ dataLock.wait()
+ }
+ return data
+ }
+
+ @JvmStatic
+ fun executeInline(config: KeyboardConfig) {
+ NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) }
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdType
+ enum class SwkbdType {
+ Normal,
+ NumberPad,
+ Qwerty,
+ Unknown3,
+ Latin,
+ SimplifiedChinese,
+ TraditionalChinese,
+ Korean
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdPasswordMode
+ enum class SwkbdPasswordMode {
+ Disabled,
+ Enabled
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdResult
+ enum class SwkbdResult {
+ Ok,
+ Cancel
+ }
+
+ @Keep
+ data class KeyboardConfig(
+ var ok_text: String? = null,
+ var header_text: String? = null,
+ var sub_text: String? = null,
+ var guide_text: String? = null,
+ var initial_text: String? = null,
+ var left_optional_symbol_key: Short = 0,
+ var right_optional_symbol_key: Short = 0,
+ var max_text_length: Int = 0,
+ var min_text_length: Int = 0,
+ var initial_cursor_position: Int = 0,
+ var type: Int = 0,
+ var password_mode: Int = 0,
+ var text_draw_type: Int = 0,
+ var key_disable_flags: Int = 0,
+ var use_blur_background: Boolean = false,
+ var enable_backspace_button: Boolean = false,
+ var enable_return_button: Boolean = false,
+ var disable_cancel_button: Boolean = false
+ ) : Serializable
+
+ // Corresponds to Frontend::KeyboardData
+ @Keep
+ data class KeyboardData(var result: Int, var text: String)
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt
new file mode 100644
index 000000000..607a3d506
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.applets.keyboard.ui
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.InputFilter
+import android.text.InputType
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard
+import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig
+import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding
+import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
+
+class KeyboardDialogFragment : DialogFragment() {
+ private lateinit var binding: DialogEditTextBinding
+ private lateinit var config: KeyboardConfig
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ binding = DialogEditTextBinding.inflate(layoutInflater)
+ config = requireArguments().serializable(CONFIG)!!
+
+ // Set up the input
+ binding.editText.hint = config.initial_text
+ binding.editText.isSingleLine = !config.enable_return_button
+ binding.editText.filters =
+ arrayOf<InputFilter>(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
new file mode 100644
index 000000000..3b1559c80
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache
+
+import androidx.annotation.Keep
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment
+
+@Keep
+object DiskShaderCacheProgress {
+ val finishLock = Object()
+ private lateinit var fragment: ShaderProgressDialogFragment
+
+ private fun prepareDialog() {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()!!
+ emulationActivity.runOnUiThread {
+ fragment = ShaderProgressDialogFragment.newInstance(
+ emulationActivity.getString(R.string.loading),
+ emulationActivity.getString(R.string.preparing_shaders)
+ )
+ fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG)
+ }
+ synchronized(finishLock) { finishLock.wait() }
+ }
+
+ @JvmStatic
+ fun loadProgress(stage: Int, progress: Int, max: Int) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+ ?: error("[DiskShaderCacheProgress] EmulationActivity not present")
+
+ when (LoadCallbackStage.values()[stage]) {
+ LoadCallbackStage.Prepare -> prepareDialog()
+ LoadCallbackStage.Build -> fragment.onUpdateProgress(
+ emulationActivity.getString(R.string.building_shaders),
+ progress,
+ max
+ )
+ LoadCallbackStage.Complete -> fragment.dismiss()
+ }
+ }
+
+ // Equivalent to VideoCore::LoadCallbackStage
+ enum class LoadCallbackStage {
+ Prepare, Build, Complete
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt
new file mode 100644
index 000000000..bf6f0366d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class ShaderProgressViewModel : ViewModel() {
+ private val _progress = MutableLiveData(0)
+ val progress: LiveData<Int> get() = _progress
+
+ private val _max = MutableLiveData(0)
+ val max: LiveData<Int> get() = _max
+
+ private val _message = MutableLiveData("")
+ val message: LiveData<String> get() = _message
+
+ fun setProgress(progress: Int) {
+ _progress.postValue(progress)
+ }
+
+ fun setMax(max: Int) {
+ _max.postValue(max)
+ }
+
+ fun setMessage(msg: String) {
+ _message.postValue(msg)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt
new file mode 100644
index 000000000..2c68c9ac3
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache.ui
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress
+import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel
+
+class ShaderProgressDialogFragment : DialogFragment() {
+ private var _binding: DialogProgressBarBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var alertDialog: AlertDialog
+
+ private lateinit var shaderProgressViewModel: ShaderProgressViewModel
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ _binding = DialogProgressBarBinding.inflate(layoutInflater)
+ shaderProgressViewModel =
+ ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java]
+
+ val title = requireArguments().getString(TITLE)
+ val message = requireArguments().getString(MESSAGE)
+
+ isCancelable = false
+ alertDialog = MaterialAlertDialogBuilder(requireActivity())
+ .setView(binding.root)
+ .setTitle(title)
+ .setMessage(message)
+ .create()
+ return alertDialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress ->
+ binding.progressBar.progress = progress
+ setUpdateText()
+ }
+ shaderProgressViewModel.max.observe(viewLifecycleOwner) { max ->
+ binding.progressBar.max = max
+ setUpdateText()
+ }
+ shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg ->
+ alertDialog.setMessage(msg)
+ }
+ synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ fun onUpdateProgress(msg: String, progress: Int, max: Int) {
+ shaderProgressViewModel.setProgress(progress)
+ shaderProgressViewModel.setMax(max)
+ shaderProgressViewModel.setMessage(msg)
+ }
+
+ private fun setUpdateText() {
+ binding.progressText.text = String.format(
+ "%d/%d",
+ shaderProgressViewModel.progress.value,
+ shaderProgressViewModel.max.value
+ )
+ }
+
+ companion object {
+ const val TAG = "ProgressDialogFragment"
+ const val TITLE = "title"
+ const val MESSAGE = "message"
+
+ fun newInstance(title: String, message: String): ShaderProgressDialogFragment {
+ val frag = ShaderProgressDialogFragment()
+ val args = Bundle()
+ args.putString(TITLE, title)
+ args.putString(MESSAGE, message)
+ frag.arguments = args
+ return frag
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
new file mode 100644
index 000000000..4c3a9ca80
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
@@ -0,0 +1,302 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// SPDX-License-Identifier: MPL-2.0
+// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
+
+package org.yuzu.yuzu_emu.features
+
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsProvider
+import android.webkit.MimeTypeMap
+import org.yuzu.yuzu_emu.BuildConfig
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.getPublicFilesDir
+import java.io.*
+
+class DocumentProvider : DocumentsProvider() {
+ private val baseDirectory: File
+ get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath)
+
+ companion object {
+ private val DEFAULT_ROOT_PROJECTION: Array<String> = 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<String> = 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<out String>?): 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<out String>?): 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<out String>?,
+ sortOrder: String?
+ ): Cursor {
+ var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+
+ val parent = getFile(parentDocumentId!!)
+ for (file in parent.listFiles()!!)
+ cursor = includeFile(cursor, null, file)
+
+ return cursor
+ }
+
+ override fun openDocument(
+ documentId: String?,
+ mode: String?,
+ signal: CancellationSignal?
+ ): ParcelFileDescriptor {
+ val file = documentId?.let { getFile(it) }
+ val accessMode = ParcelFileDescriptor.parseMode(mode)
+ return ParcelFileDescriptor.open(file, accessMode)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt
new file mode 100644
index 000000000..a6e9833ee
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractBooleanSetting : AbstractSetting {
+ var boolean: Boolean
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt
new file mode 100644
index 000000000..6fe4bc263
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractFloatSetting : AbstractSetting {
+ var float: Float
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt
new file mode 100644
index 000000000..892b7dcfe
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractIntSetting : AbstractSetting {
+ var int: Int
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt
new file mode 100644
index 000000000..258580209
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractSetting {
+ val key: String?
+ val section: String?
+ val isRuntimeEditable: Boolean
+ val valueAsString: String
+ val defaultValue: Any
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt
new file mode 100644
index 000000000..0d02c5997
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractStringSetting : AbstractSetting {
+ var string: String
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
new file mode 100644
index 000000000..3dfd66779
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class BooleanSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Boolean
+) : AbstractBooleanSetting {
+ USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false);
+
+ override var boolean: Boolean = defaultValue
+
+ override val valueAsString: String
+ get() = boolean.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ USE_CUSTOM_RTC
+ )
+
+ fun from(key: String): BooleanSetting? =
+ BooleanSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt
new file mode 100644
index 000000000..e5545a916
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class FloatSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Float
+) : AbstractFloatSetting {
+ // No float settings currently exist
+ EMPTY_SETTING("", "", 0f);
+
+ override var float: Float = defaultValue
+
+ override val valueAsString: String
+ get() = float.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>()
+
+ fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
new file mode 100644
index 000000000..c5722a5a1
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
@@ -0,0 +1,131 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class IntSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Int
+) : AbstractIntSetting {
+ RENDERER_USE_SPEED_LIMIT(
+ "use_speed_limit",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ USE_DOCKED_MODE(
+ "use_docked_mode",
+ Settings.SECTION_SYSTEM,
+ 0
+ ),
+ RENDERER_USE_DISK_SHADER_CACHE(
+ "use_disk_shader_cache",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_FORCE_MAX_CLOCK(
+ "force_max_clock",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ASYNCHRONOUS_SHADERS(
+ "use_asynchronous_shaders",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_DEBUG(
+ "debug",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_SPEED_LIMIT(
+ "speed_limit",
+ Settings.SECTION_RENDERER,
+ 100
+ ),
+ CPU_ACCURACY(
+ "cpu_accuracy",
+ Settings.SECTION_CPU,
+ 0
+ ),
+ REGION_INDEX(
+ "region_index",
+ Settings.SECTION_SYSTEM,
+ -1
+ ),
+ LANGUAGE_INDEX(
+ "language_index",
+ Settings.SECTION_SYSTEM,
+ 1
+ ),
+ RENDERER_BACKEND(
+ "backend",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ACCURACY(
+ "gpu_accuracy",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_RESOLUTION(
+ "resolution_setup",
+ Settings.SECTION_RENDERER,
+ 2
+ ),
+ RENDERER_VSYNC(
+ "use_vsync",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_SCALING_FILTER(
+ "scaling_filter",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ANTI_ALIASING(
+ "anti_aliasing",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_ASPECT_RATIO(
+ "aspect_ratio",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ AUDIO_VOLUME(
+ "volume",
+ Settings.SECTION_AUDIO,
+ 100
+ );
+
+ override var int: Int = defaultValue
+
+ override val valueAsString: String
+ get() = int.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ RENDERER_USE_DISK_SHADER_CACHE,
+ RENDERER_ASYNCHRONOUS_SHADERS,
+ RENDERER_DEBUG,
+ RENDERER_BACKEND,
+ RENDERER_RESOLUTION,
+ RENDERER_VSYNC
+ )
+
+ fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = IntSetting.values().forEach { it.int = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt
new file mode 100644
index 000000000..474f598a9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+/**
+ * A semantically-related group of Settings objects. These Settings are
+ * internally stored as a HashMap.
+ */
+class SettingSection(val name: String) {
+ val settings = HashMap<String, AbstractSetting>()
+
+ /**
+ * Convenience method; inserts a value directly into the backing HashMap.
+ *
+ * @param setting The Setting to be inserted.
+ */
+ fun putSetting(setting: AbstractSetting) {
+ settings[setting.key!!] = setting
+ }
+
+ /**
+ * Convenience method; gets a value directly from the backing HashMap.
+ *
+ * @param key Used to retrieve the Setting.
+ * @return A Setting object (you should probably cast this before using)
+ */
+ fun getSetting(key: String): AbstractSetting? {
+ return settings[key]
+ }
+
+ fun mergeSection(settingSection: SettingSection) {
+ for (setting in settingSection.settings.values) {
+ putSetting(setting)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
new file mode 100644
index 000000000..8df20b928
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+import android.text.TextUtils
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import java.util.*
+
+class Settings {
+ private var gameId: String? = null
+
+ var isLoaded = false
+
+ /**
+ * A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null
+ * when getting a key not already in the map
+ */
+ class SettingsSectionMap : HashMap<String, SettingSection?>() {
+ override operator fun get(key: String): SettingSection? {
+ if (!super.containsKey(key)) {
+ val section = SettingSection(key)
+ super.put(key, section)
+ return section
+ }
+ return super.get(key)
+ }
+ }
+
+ var sections: HashMap<String, SettingSection?> = SettingsSectionMap()
+
+ fun getSection(sectionName: String): SettingSection? {
+ return sections[sectionName]
+ }
+
+ val isEmpty: Boolean
+ get() = sections.isEmpty()
+
+ fun loadSettings(view: SettingsActivityView? = null) {
+ sections = SettingsSectionMap()
+ loadYuzuSettings(view)
+ if (!TextUtils.isEmpty(gameId)) {
+ loadCustomGameSettings(gameId!!, view)
+ }
+ isLoaded = true
+ }
+
+ private fun loadYuzuSettings(view: SettingsActivityView?) {
+ for ((fileName) in configFileSectionsMap) {
+ sections.putAll(SettingsFile.readFile(fileName, view))
+ }
+ }
+
+ private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView?) {
+ // Custom game settings
+ mergeSections(SettingsFile.readCustomGameSettings(gameId, view))
+ }
+
+ private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) {
+ for ((key, updatedSection) in updatedSections) {
+ if (sections.containsKey(key)) {
+ val originalSection = sections[key]
+ originalSection!!.mergeSection(updatedSection!!)
+ } else {
+ sections[key] = updatedSection
+ }
+ }
+ }
+
+ fun loadSettings(gameId: String, view: SettingsActivityView) {
+ this.gameId = gameId
+ loadSettings(view)
+ }
+
+ fun saveSettings(view: SettingsActivityView) {
+ if (TextUtils.isEmpty(gameId)) {
+ view.showToastMessage(
+ YuzuApplication.appContext.getString(R.string.ini_saved),
+ false
+ )
+
+ for ((fileName, sectionNames) in configFileSectionsMap) {
+ val iniSections = TreeMap<String, SettingSection>()
+ for (section in sectionNames) {
+ iniSections[section] = sections[section]!!
+ }
+
+ SettingsFile.saveFile(fileName, iniSections, view)
+ }
+ } else {
+ // Custom game settings
+ view.showToastMessage(
+ YuzuApplication.appContext.getString(R.string.gameid_saved, gameId),
+ false
+ )
+
+ SettingsFile.saveCustomGameSettings(gameId, sections)
+ }
+ }
+
+ companion object {
+ const val SECTION_GENERAL = "General"
+ const val SECTION_SYSTEM = "System"
+ const val SECTION_RENDERER = "Renderer"
+ const val SECTION_AUDIO = "Audio"
+ const val SECTION_CPU = "Cpu"
+ const val SECTION_THEME = "Theme"
+ const val SECTION_DEBUG = "Debug"
+
+ const val PREF_OVERLAY_INIT = "OverlayInit"
+ const val PREF_CONTROL_SCALE = "controlScale"
+ const val PREF_CONTROL_OPACITY = "controlOpacity"
+ const val PREF_TOUCH_ENABLED = "isTouchEnabled"
+ const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0"
+ const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1"
+ const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2"
+ const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3"
+ const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4"
+ const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5"
+ const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6"
+ const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7"
+ const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8"
+ const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9"
+ const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10"
+ const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11"
+ const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12"
+ const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13"
+ const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14"
+
+ const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter"
+ const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable"
+ const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics"
+ const val PREF_MENU_SETTINGS_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout"
+ const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps"
+ const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay"
+
+ const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
+ const val PREF_THEME = "Theme"
+ const val PREF_THEME_MODE = "ThemeMode"
+ const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds"
+
+ private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap()
+
+ init {
+ configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] =
+ listOf(
+ SECTION_GENERAL,
+ SECTION_SYSTEM,
+ SECTION_RENDERER,
+ SECTION_AUDIO,
+ SECTION_CPU
+ )
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt
new file mode 100644
index 000000000..bd9233d62
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+import androidx.lifecycle.ViewModel
+
+class SettingsViewModel : ViewModel() {
+ val settings = Settings()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt
new file mode 100644
index 000000000..63f95690c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class StringSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: String
+) : AbstractStringSetting {
+ CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0");
+
+ override var string: String = defaultValue
+
+ override val valueAsString: String
+ get() = string
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ CUSTOM_RTC
+ )
+
+ fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = StringSetting.values().forEach { it.string = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt
new file mode 100644
index 000000000..bc0bf7788
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+
+class DateTimeSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val key: String? = null,
+ private val defaultValue: String? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_DATETIME_SETTING
+
+ val value: String
+ get() = if (setting != null) {
+ val setting = setting as AbstractStringSetting
+ setting.string
+ } else {
+ defaultValue!!
+ }
+
+ fun setSelectedValue(datetime: String): AbstractStringSetting {
+ val stringSetting = setting as AbstractStringSetting
+ stringSetting.string = datetime
+ return stringSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt
new file mode 100644
index 000000000..0f8edbfb0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class HeaderSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_HEADER
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt
new file mode 100644
index 000000000..caaab50d8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.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
+
+class RunnableSetting(
+ titleId: Int,
+ descriptionId: Int,
+ val isRuntimeRunnable: Boolean,
+ val runnable: () -> Unit
+) : SettingsItem(null, titleId, descriptionId) {
+ override val type = TYPE_RUNNABLE
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
new file mode 100644
index 000000000..07520849e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+/**
+ * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
+ * Each one corresponds to a [AbstractSetting] object, so this class's subclasses
+ * should vaguely correspond to those subclasses. There are a few with multiple analogues
+ * and a few with none (Headers, for example, do not correspond to anything in the ini
+ * file.)
+ */
+abstract class SettingsItem(
+ var setting: AbstractSetting?,
+ val nameId: Int,
+ val descriptionId: Int
+) {
+ abstract val type: Int
+
+ val isEditable: Boolean
+ get() {
+ if (!NativeLibrary.isRunning()) return true
+ return setting?.isRuntimeEditable ?: false
+ }
+
+ companion object {
+ const val TYPE_HEADER = 0
+ const val TYPE_SWITCH = 1
+ const val TYPE_SINGLE_CHOICE = 2
+ const val TYPE_SLIDER = 3
+ const val TYPE_SUBMENU = 4
+ const val TYPE_STRING_SINGLE_CHOICE = 5
+ const val TYPE_DATETIME_SETTING = 6
+ const val TYPE_RUNNABLE = 7
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt
new file mode 100644
index 000000000..9eac9904e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+
+class SingleChoiceSetting(
+ setting: AbstractIntSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val choicesId: Int,
+ val valuesId: Int,
+ val key: String? = null,
+ val defaultValue: Int? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SINGLE_CHOICE
+
+ val selectedValue: Int
+ get() = if (setting != null) {
+ val setting = setting as AbstractIntSetting
+ setting.int
+ } else {
+ defaultValue!!
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Int): AbstractIntSetting {
+ val intSetting = setting as AbstractIntSetting
+ intSetting.int = selection
+ return intSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt
new file mode 100644
index 000000000..842648ce4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.utils.Log
+import kotlin.math.roundToInt
+
+class SliderSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val min: Int,
+ val max: Int,
+ val units: String,
+ val key: String? = null,
+ val defaultValue: Int? = null,
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SLIDER
+
+ val selectedValue: Int
+ get() {
+ val setting = setting ?: return defaultValue!!
+ return when (setting) {
+ is AbstractIntSetting -> setting.int
+ is AbstractFloatSetting -> setting.float.roundToInt()
+ else -> {
+ Log.error("[SliderSetting] Error casting setting type.")
+ -1
+ }
+ }
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Int): AbstractIntSetting {
+ val intSetting = setting as AbstractIntSetting
+ intSetting.int = selection
+ return intSetting
+ }
+
+ /**
+ * Write a value to the backing float. If that float was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the float.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Float): AbstractFloatSetting {
+ val floatSetting = setting as AbstractFloatSetting
+ floatSetting.float = selection
+ return floatSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt
new file mode 100644
index 000000000..9e9b00d10
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+
+class StringSingleChoiceSetting(
+ val key: String? = null,
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val choicesId: Array<String>,
+ private val valuesId: Array<String>?,
+ private val defaultValue: String? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_STRING_SINGLE_CHOICE
+
+ fun getValueAt(index: Int): String? {
+ if (valuesId == null) return null
+ return if (index >= 0 && index < valuesId.size) {
+ valuesId[index]
+ } else ""
+ }
+
+ val selectedValue: String
+ get() = if (setting != null) {
+ val setting = setting as AbstractStringSetting
+ setting.string
+ } else {
+ defaultValue!!
+ }
+ val selectValueIndex: Int
+ get() {
+ val selectedValue = selectedValue
+ for (i in valuesId!!.indices) {
+ if (valuesId[i] == selectedValue) {
+ return i
+ }
+ }
+ return -1
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: String): AbstractStringSetting {
+ val stringSetting = setting as AbstractStringSetting
+ stringSetting.string = selection
+ return stringSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt
new file mode 100644
index 000000000..a3ef59c2f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class SubmenuSetting(
+ titleId: Int,
+ descriptionId: Int,
+ val menuKey: String
+) : SettingsItem(null, titleId, descriptionId) {
+ override val type = TYPE_SUBMENU
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt
new file mode 100644
index 000000000..90b198718
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class SwitchSetting(
+ setting: AbstractSetting,
+ titleId: Int,
+ descriptionId: Int,
+ val key: String? = null,
+ val defaultValue: Any? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SWITCH
+
+ val isChecked: Boolean
+ get() {
+ if (setting == null) {
+ return defaultValue as Boolean
+ }
+
+ // Try integer setting
+ try {
+ val setting = setting as AbstractIntSetting
+ return setting.int == 1
+ } catch (_: ClassCastException) {
+ }
+
+ // Try boolean setting
+ try {
+ val setting = setting as AbstractBooleanSetting
+ return setting.boolean
+ } catch (_: ClassCastException) {
+ }
+ return defaultValue as Boolean
+ }
+
+ /**
+ * Write a value to the backing boolean. If that boolean was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param checked Pretty self explanatory.
+ * @return the existing setting with the new value applied.
+ */
+ fun setChecked(checked: Boolean): AbstractSetting {
+ // Try integer setting
+ try {
+ val setting = setting as AbstractIntSetting
+ setting.int = if (checked) 1 else 0
+ return setting
+ } catch (_: ClassCastException) {
+ }
+
+ // Try boolean setting
+ val setting = setting as AbstractBooleanSetting
+ setting.boolean = checked
+ return setting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
new file mode 100644
index 000000000..72e2cce2a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
@@ -0,0 +1,243 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.View
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.activity.OnBackPressedCallback
+import androidx.core.view.updatePadding
+import com.google.android.material.color.MaterialColors
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
+import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.utils.*
+import java.io.IOException
+
+class SettingsActivity : AppCompatActivity(), SettingsActivityView {
+ private val presenter = SettingsActivityPresenter(this)
+
+ private lateinit var binding: ActivitySettingsBinding
+
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override val settings: Settings get() = settingsViewModel.settings
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ binding = ActivitySettingsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ val launcher = intent
+ val gameID = launcher.getStringExtra(ARG_GAME_ID)
+ val menuTag = launcher.getStringExtra(ARG_MENU_TAG)
+ presenter.onCreate(savedInstanceState, menuTag!!, gameID!!)
+
+ // Show "Back" button in the action bar for navigation
+ setSupportActionBar(binding.toolbarSettings)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+ if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
+ binding.navigationBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.navigationBarShade,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ }
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() = navigateBack()
+ })
+
+ setInsets()
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ navigateBack()
+ return true
+ }
+
+ private fun navigateBack() {
+ if (supportFragmentManager.backStackEntryCount > 0) {
+ supportFragmentManager.popBackStack()
+ } else {
+ finish()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ val inflater = menuInflater
+ inflater.inflate(R.menu.menu_settings, menu)
+ return true
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ // Critical: If super method is not called, rotations will be busted.
+ super.onSaveInstanceState(outState)
+ presenter.saveState(outState)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ presenter.onStart()
+ }
+
+ /**
+ * If this is called, the user has left the settings screen (potentially through the
+ * home button) and will expect their changes to be persisted. So we kick off an
+ * IntentService which will do so on a background thread.
+ */
+ override fun onStop() {
+ super.onStop()
+ presenter.onStop(isFinishing)
+ }
+
+ override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) {
+ if (!addToStack && settingsFragment != null) {
+ return
+ }
+
+ val transaction = supportFragmentManager.beginTransaction()
+ if (addToStack) {
+ if (areSystemAnimationsEnabled()) {
+ transaction.setCustomAnimations(
+ R.anim.anim_settings_fragment_in,
+ R.anim.anim_settings_fragment_out,
+ 0,
+ R.anim.anim_pop_settings_fragment_out
+ )
+ }
+ transaction.addToBackStack(null)
+ }
+ transaction.replace(
+ R.id.frame_content,
+ SettingsFragment.newInstance(menuTag, gameId),
+ FRAGMENT_TAG
+ )
+ transaction.commit()
+ }
+
+ private fun areSystemAnimationsEnabled(): Boolean {
+ val duration = android.provider.Settings.Global.getFloat(
+ contentResolver,
+ android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f
+ )
+ val transition = android.provider.Settings.Global.getFloat(
+ contentResolver,
+ android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f
+ )
+ return duration != 0f && transition != 0f
+ }
+
+ override fun onSettingsFileLoaded() {
+ val fragment: SettingsFragmentView? = settingsFragment
+ fragment?.loadSettingsList()
+ }
+
+ override fun onSettingsFileNotFound() {
+ val fragment: SettingsFragmentView? = settingsFragment
+ fragment?.loadSettingsList()
+ }
+
+ override fun showToastMessage(message: String, is_long: Boolean) {
+ Toast.makeText(
+ this,
+ message,
+ if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ override fun onSettingChanged() {
+ presenter.onSettingChanged()
+ }
+
+ fun onSettingsReset() {
+ // Prevents saving to a non-existent settings file
+ presenter.onSettingsReset()
+
+ // Reset the static memory representation of each setting
+ BooleanSetting.clear()
+ FloatSetting.clear()
+ IntSetting.clear()
+ StringSetting.clear()
+
+ // Delete settings file because the user may have changed values that do not exist in the UI
+ val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
+ if (!settingsFile.delete()) {
+ throw IOException("Failed to delete $settingsFile")
+ }
+
+ showToastMessage(getString(R.string.settings_reset), true)
+ finish()
+ }
+
+ fun setToolbarTitle(title: String) {
+ binding.toolbarSettingsLayout.title = title
+ }
+
+ private val settingsFragment: SettingsFragment?
+ get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment?
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ view.updatePadding(
+ left = barInsets.left + cutoutInsets.left,
+ right = barInsets.right + cutoutInsets.right
+ )
+
+ val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left
+ mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right
+ binding.appbarSettings.layoutParams = mlpAppBar
+
+ val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
+ mlpShade.height = barInsets.bottom
+ binding.navigationBarShade.layoutParams = mlpShade
+
+ windowInsets
+ }
+ }
+
+ companion object {
+ private const val ARG_MENU_TAG = "menu_tag"
+ private const val ARG_GAME_ID = "game_id"
+ private const val FRAGMENT_TAG = "settings"
+
+ fun launch(context: Context, menuTag: String?, gameId: String?) {
+ val settings = Intent(context, SettingsActivity::class.java)
+ settings.putExtra(ARG_MENU_TAG, menuTag)
+ settings.putExtra(ARG_GAME_ID, gameId)
+ context.startActivity(settings)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt
new file mode 100644
index 000000000..4361d95fb
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.text.TextUtils
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.Log
+import java.io.File
+
+class SettingsActivityPresenter(private val activityView: SettingsActivityView) {
+ val settings: Settings get() = activityView.settings
+
+ private var shouldSave = false
+ private lateinit var menuTag: String
+ private lateinit var gameId: String
+
+ fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
+ this.menuTag = menuTag
+ this.gameId = gameId
+ if (savedInstanceState != null) {
+ shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
+ }
+ }
+
+ fun onStart() {
+ prepareDirectoriesIfNeeded()
+ }
+
+ private fun loadSettingsUI() {
+ if (!settings.isLoaded) {
+ if (!TextUtils.isEmpty(gameId)) {
+ settings.loadSettings(gameId, activityView)
+ } else {
+ settings.loadSettings(activityView)
+ }
+ }
+ activityView.showSettingsFragment(menuTag, false, gameId)
+ activityView.onSettingsFileLoaded()
+ }
+
+ private fun prepareDirectoriesIfNeeded() {
+ val configFile =
+ File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
+ if (!configFile.exists()) {
+ Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
+ Log.error("yuzu config file could not be found!")
+ }
+
+ if (!DirectoryInitialization.areDirectoriesReady) {
+ DirectoryInitialization.start(activityView as Context)
+ }
+ loadSettingsUI()
+ }
+
+ fun onStop(finishing: Boolean) {
+ if (finishing && shouldSave) {
+ Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
+ settings.saveSettings(activityView)
+ }
+ NativeLibrary.reloadSettings()
+ }
+
+ fun onSettingChanged() {
+ shouldSave = true
+ }
+
+ fun onSettingsReset() {
+ shouldSave = false
+ }
+
+ fun saveState(outState: Bundle) {
+ outState.putBoolean(KEY_SHOULD_SAVE, shouldSave)
+ }
+
+ companion object {
+ private const val KEY_SHOULD_SAVE = "should_save"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt
new file mode 100644
index 000000000..c186fc388
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+
+/**
+ * Abstraction for the Activity that manages SettingsFragments.
+ */
+interface SettingsActivityView {
+ /**
+ * Show a new SettingsFragment.
+ *
+ * @param menuTag Identifier for the settings group that should be displayed.
+ * @param addToStack Whether or not this fragment should replace a previous one.
+ */
+ fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String)
+
+ /**
+ * Called by a contained Fragment to get access to the Setting HashMap
+ * loaded from disk, so that each Fragment doesn't need to perform its own
+ * read operation.
+ *
+ * @return A HashMap of Settings.
+ */
+ val settings: Settings
+
+ /**
+ * Called when a load operation completes.
+ */
+ fun onSettingsFileLoaded()
+
+ /**
+ * Called when a load operation fails.
+ */
+ fun onSettingsFileNotFound()
+
+ /**
+ * Display a popup text message on screen.
+ *
+ * @param message The contents of the onscreen message.
+ * @param is_long Whether this should be a long Toast or short one.
+ */
+ fun showToastMessage(message: String, is_long: Boolean)
+
+ /**
+ * End the activity.
+ */
+ fun finish()
+
+ /**
+ * Called by a containing Fragment to tell the Activity that a setting was changed;
+ * unless this has been called, the Activity will not save to disk.
+ */
+ fun onSettingChanged()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
new file mode 100644
index 000000000..1eb4899fc
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
@@ -0,0 +1,340 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.content.DialogInterface
+import android.icu.util.Calendar
+import android.icu.util.TimeZone
+import android.text.format.DateFormat
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.setFragmentResultListener
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.slider.Slider
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.*
+import org.yuzu.yuzu_emu.features.settings.ui.viewholder.*
+
+class SettingsAdapter(
+ private val fragmentView: SettingsFragmentView,
+ private val context: Context
+) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener {
+ private var settings: ArrayList<SettingsItem>? = null
+ private var clickedItem: SettingsItem? = null
+ private var clickedPosition: Int
+ private var dialog: AlertDialog? = null
+ private var sliderProgress = 0
+ private var textSliderValue: TextView? = null
+
+ private var defaultCancelListener =
+ DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() }
+
+ init {
+ clickedPosition = -1
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ SettingsItem.TYPE_HEADER -> {
+ HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SWITCH -> {
+ SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> {
+ SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SLIDER -> {
+ SliderViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SUBMENU -> {
+ SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_DATETIME_SETTING -> {
+ DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_RUNNABLE -> {
+ RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ else -> {
+ // TODO: Create an error view since we can't return null now
+ HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
+ }
+ }
+ }
+
+ override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ private fun getItem(position: Int): SettingsItem {
+ return settings!![position]
+ }
+
+ override fun getItemCount(): Int {
+ return if (settings != null) {
+ settings!!.size
+ } else {
+ 0
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return getItem(position).type
+ }
+
+ fun setSettingsList(settings: ArrayList<SettingsItem>?) {
+ this.settings = settings
+ notifyDataSetChanged()
+ }
+
+ fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) {
+ val setting = item.setChecked(checked)
+ fragmentView.putSetting(setting)
+ fragmentView.onSettingChanged()
+ }
+
+ private fun onSingleChoiceClick(item: SingleChoiceSetting) {
+ clickedItem = item
+ val value = getSelectionForSingleChoiceValue(item)
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setSingleChoiceItems(item.choicesId, value, this)
+ .show()
+ }
+
+ fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
+ clickedPosition = position
+ onSingleChoiceClick(item)
+ }
+
+ private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
+ clickedItem = item
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setSingleChoiceItems(item.choicesId, item.selectValueIndex, this)
+ .show()
+ }
+
+ fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) {
+ clickedPosition = position
+ onStringSingleChoiceClick(item)
+ }
+
+ fun onDateTimeClick(item: DateTimeSetting, position: Int) {
+ clickedItem = item
+ clickedPosition = position
+ val storedTime = java.lang.Long.decode(item.value) * 1000
+
+ // Helper to extract hour and minute from epoch time
+ val calendar: Calendar = Calendar.getInstance()
+ calendar.timeInMillis = storedTime
+ calendar.timeZone = TimeZone.getTimeZone("UTC")
+
+ var timeFormat: Int = TimeFormat.CLOCK_12H
+ if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) {
+ timeFormat = TimeFormat.CLOCK_24H
+ }
+
+ val datePicker: MaterialDatePicker<Long> = MaterialDatePicker.Builder.datePicker()
+ .setSelection(storedTime)
+ .setTitleText(R.string.select_rtc_date)
+ .build()
+ val timePicker: MaterialTimePicker = MaterialTimePicker.Builder()
+ .setTimeFormat(timeFormat)
+ .setHour(calendar.get(Calendar.HOUR_OF_DAY))
+ .setMinute(calendar.get(Calendar.MINUTE))
+ .setTitleText(R.string.select_rtc_time)
+ .build()
+
+ datePicker.addOnPositiveButtonClickListener {
+ timePicker.show(
+ (fragmentView.activityView as AppCompatActivity).supportFragmentManager,
+ "TimePicker"
+ )
+ }
+ timePicker.addOnPositiveButtonClickListener {
+ var epochTime: Long = datePicker.selection!! / 1000
+ epochTime += timePicker.hour.toLong() * 60 * 60
+ epochTime += timePicker.minute.toLong() * 60
+ val rtcString = epochTime.toString()
+ if (item.value != rtcString) {
+ fragmentView.onSettingChanged()
+ }
+ notifyItemChanged(clickedPosition)
+ val setting = item.setSelectedValue(rtcString)
+ fragmentView.putSetting(setting)
+ clickedItem = null
+ }
+ datePicker.show(
+ (fragmentView.activityView as AppCompatActivity).supportFragmentManager,
+ "DatePicker"
+ )
+ }
+
+ fun onSliderClick(item: SliderSetting, position: Int) {
+ clickedItem = item
+ clickedPosition = position
+ sliderProgress = item.selectedValue
+
+ val inflater = LayoutInflater.from(context)
+ val sliderBinding = DialogSliderBinding.inflate(inflater)
+
+ textSliderValue = sliderBinding.textValue
+ textSliderValue!!.text = sliderProgress.toString()
+ sliderBinding.textUnits.text = item.units
+
+ sliderBinding.slider.apply {
+ valueFrom = item.min.toFloat()
+ valueTo = item.max.toFloat()
+ value = sliderProgress.toFloat()
+ addOnChangeListener { _: Slider, value: Float, _: Boolean ->
+ sliderProgress = value.toInt()
+ textSliderValue!!.text = sliderProgress.toString()
+ }
+ }
+
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setView(sliderBinding.root)
+ .setPositiveButton(android.R.string.ok, this)
+ .setNegativeButton(android.R.string.cancel, defaultCancelListener)
+ .setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int ->
+ sliderBinding.slider.value = item.defaultValue!!.toFloat()
+ onClick(dialog, which)
+ }
+ .show()
+ }
+
+ fun onSubmenuClick(item: SubmenuSetting) {
+ fragmentView.loadSubMenu(item.menuKey)
+ }
+
+ override fun onClick(dialog: DialogInterface, which: Int) {
+ when (clickedItem) {
+ is SingleChoiceSetting -> {
+ val scSetting = clickedItem as SingleChoiceSetting
+ val value = getValueForSingleChoiceSelection(scSetting, which)
+ if (scSetting.selectedValue != value) {
+ fragmentView.onSettingChanged()
+ }
+
+ // Get the backing Setting, which may be null (if for example it was missing from the file)
+ val setting = scSetting.setSelectedValue(value)
+ fragmentView.putSetting(setting)
+ closeDialog()
+ }
+
+ is StringSingleChoiceSetting -> {
+ val scSetting = clickedItem as StringSingleChoiceSetting
+ val value = scSetting.getValueAt(which)
+ if (scSetting.selectedValue != value) fragmentView.onSettingChanged()
+ val setting = scSetting.setSelectedValue(value!!)
+ fragmentView.putSetting(setting)
+ closeDialog()
+ }
+
+ is SliderSetting -> {
+ val sliderSetting = clickedItem as SliderSetting
+ if (sliderSetting.selectedValue != sliderProgress) {
+ fragmentView.onSettingChanged()
+ }
+ if (sliderSetting.setting is FloatSetting) {
+ val value = sliderProgress.toFloat()
+ val setting = sliderSetting.setSelectedValue(value)
+ fragmentView.putSetting(setting)
+ } else {
+ val setting = sliderSetting.setSelectedValue(sliderProgress)
+ fragmentView.putSetting(setting)
+ }
+ closeDialog()
+ }
+ }
+ clickedItem = null
+ sliderProgress = -1
+ }
+
+ fun onLongClick(setting: AbstractSetting, position: Int): Boolean {
+ MaterialAlertDialogBuilder(context)
+ .setMessage(R.string.reset_setting_confirmation)
+ .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int ->
+ when (setting) {
+ is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
+ is AbstractFloatSetting -> setting.float = setting.defaultValue as Float
+ is AbstractIntSetting -> setting.int = setting.defaultValue as Int
+ is AbstractStringSetting -> setting.string = setting.defaultValue as String
+ }
+ notifyItemChanged(position)
+ fragmentView.onSettingChanged()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+
+ return true
+ }
+
+ fun closeDialog() {
+ if (dialog != null) {
+ if (clickedPosition != -1) {
+ notifyItemChanged(clickedPosition)
+ clickedPosition = -1
+ }
+ dialog!!.dismiss()
+ dialog = null
+ }
+ }
+
+ private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
+ val valuesId = item.valuesId
+ return if (valuesId > 0) {
+ val valuesArray = context.resources.getIntArray(valuesId)
+ valuesArray[which]
+ } else {
+ which
+ }
+ }
+
+ private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int {
+ val value = item.selectedValue
+ val valuesId = item.valuesId
+ if (valuesId > 0) {
+ val valuesArray = context.resources.getIntArray(valuesId)
+ for (index in valuesArray.indices) {
+ val current = valuesArray[index]
+ if (current == value) {
+ return index
+ }
+ }
+ } else {
+ return value
+ }
+ return -1
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
new file mode 100644
index 000000000..867147950
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.divider.MaterialDividerItemDecoration
+import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+
+class SettingsFragment : Fragment(), SettingsFragmentView {
+ override var activityView: SettingsActivityView? = null
+
+ private val fragmentPresenter = SettingsFragmentPresenter(this)
+ private var settingsAdapter: SettingsAdapter? = null
+
+ private var _binding: FragmentSettingsBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ activityView = requireActivity() as SettingsActivityView
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG)
+ val gameId = requireArguments().getString(ARGUMENT_GAME_ID)
+ fragmentPresenter.onCreate(menuTag!!, gameId!!)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSettingsBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ settingsAdapter = SettingsAdapter(this, requireActivity())
+ val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
+ dividerDecoration.isLastItemDecorated = false
+ binding.listSettings.apply {
+ adapter = settingsAdapter
+ layoutManager = LinearLayoutManager(activity)
+ addItemDecoration(dividerDecoration)
+ }
+ fragmentPresenter.onViewCreated()
+
+ setInsets()
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+ activityView = null
+ if (settingsAdapter != null) {
+ settingsAdapter!!.closeDialog()
+ }
+ }
+
+ override fun showSettingsList(settingsList: ArrayList<SettingsItem>) {
+ settingsAdapter!!.setSettingsList(settingsList)
+ }
+
+ override fun loadSettingsList() {
+ fragmentPresenter.loadSettingsList()
+ }
+
+ override fun loadSubMenu(menuKey: String) {
+ activityView!!.showSettingsFragment(
+ menuKey,
+ true,
+ requireArguments().getString(ARGUMENT_GAME_ID)!!
+ )
+ }
+
+ override fun showToastMessage(message: String?, is_long: Boolean) {
+ activityView!!.showToastMessage(message!!, is_long)
+ }
+
+ override fun putSetting(setting: AbstractSetting) {
+ fragmentPresenter.putSetting(setting)
+ }
+
+ override fun onSettingChanged() {
+ activityView!!.onSettingChanged()
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.updatePadding(bottom = insets.bottom)
+ windowInsets
+ }
+ }
+
+ companion object {
+ private const val ARGUMENT_MENU_TAG = "menu_tag"
+ private const val ARGUMENT_GAME_ID = "game_id"
+
+ fun newInstance(menuTag: String?, gameId: String?): Fragment {
+ val fragment = SettingsFragment()
+ val arguments = Bundle()
+ arguments.putString(ARGUMENT_MENU_TAG, menuTag)
+ arguments.putString(ARGUMENT_GAME_ID, gameId)
+ fragment.arguments = arguments
+ return fragment
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
new file mode 100644
index 000000000..061046b2e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -0,0 +1,465 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.SharedPreferences
+import android.os.Build
+import android.text.TextUtils
+import androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.*
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
+import org.yuzu.yuzu_emu.utils.ThemeHelper
+
+class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) {
+ private var menuTag: String? = null
+ private lateinit var gameId: String
+ private var settingsList: ArrayList<SettingsItem>? = null
+
+ private val settingsActivity get() = fragmentView.activityView as SettingsActivity
+ private val settings get() = fragmentView.activityView!!.settings
+
+ private lateinit var preferences: SharedPreferences
+
+ fun onCreate(menuTag: String, gameId: String) {
+ this.gameId = gameId
+ this.menuTag = menuTag
+ }
+
+ fun onViewCreated() {
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ loadSettingsList()
+ }
+
+ fun putSetting(setting: AbstractSetting) {
+ if (setting.section == null) {
+ return
+ }
+
+ val section = settings.getSection(setting.section!!)!!
+ if (section.getSetting(setting.key!!) == null) {
+ section.putSetting(setting)
+ }
+ }
+
+ fun loadSettingsList() {
+ if (!TextUtils.isEmpty(gameId)) {
+ settingsActivity.setToolbarTitle("Game Settings: $gameId")
+ }
+ val sl = ArrayList<SettingsItem>()
+ if (menuTag == null) {
+ return
+ }
+ when (menuTag) {
+ SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl)
+ Settings.SECTION_GENERAL -> addGeneralSettings(sl)
+ Settings.SECTION_SYSTEM -> addSystemSettings(sl)
+ Settings.SECTION_RENDERER -> addGraphicsSettings(sl)
+ Settings.SECTION_AUDIO -> addAudioSettings(sl)
+ Settings.SECTION_THEME -> addThemeSettings(sl)
+ Settings.SECTION_DEBUG -> addDebugSettings(sl)
+ else -> {
+ fragmentView.showToastMessage("Unimplemented menu", false)
+ return
+ }
+ }
+ settingsList = sl
+ fragmentView.showSettingsList(settingsList!!)
+ }
+
+ private fun addConfigSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.advanced_settings))
+ sl.apply {
+ add(
+ SubmenuSetting(
+ R.string.preferences_general,
+ 0,
+ Settings.SECTION_GENERAL
+ )
+ )
+ add(
+ SubmenuSetting(
+ R.string.preferences_system,
+ 0,
+ Settings.SECTION_SYSTEM
+ )
+ )
+ add(
+ SubmenuSetting(
+ R.string.preferences_graphics,
+ 0,
+ Settings.SECTION_RENDERER
+ )
+ )
+ add(
+ SubmenuSetting(
+ R.string.preferences_audio,
+ 0,
+ Settings.SECTION_AUDIO
+ )
+ )
+ add(
+ SubmenuSetting(
+ R.string.preferences_debug,
+ 0,
+ Settings.SECTION_DEBUG
+ )
+ )
+ add(
+ RunnableSetting(
+ R.string.reset_to_default,
+ 0,
+ false
+ ) {
+ ResetSettingsDialogFragment().show(
+ settingsActivity.supportFragmentManager,
+ ResetSettingsDialogFragment.TAG
+ )
+ }
+ )
+ }
+ }
+
+ private fun addGeneralSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general))
+ sl.apply {
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_USE_SPEED_LIMIT,
+ R.string.frame_limit_enable,
+ R.string.frame_limit_enable_description,
+ IntSetting.RENDERER_USE_SPEED_LIMIT.key,
+ IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue
+ )
+ )
+ add(
+ SliderSetting(
+ IntSetting.RENDERER_SPEED_LIMIT,
+ R.string.frame_limit_slider,
+ R.string.frame_limit_slider_description,
+ 1,
+ 200,
+ "%",
+ IntSetting.RENDERER_SPEED_LIMIT.key,
+ IntSetting.RENDERER_SPEED_LIMIT.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.CPU_ACCURACY,
+ R.string.cpu_accuracy,
+ 0,
+ R.array.cpuAccuracyNames,
+ R.array.cpuAccuracyValues,
+ IntSetting.CPU_ACCURACY.key,
+ IntSetting.CPU_ACCURACY.defaultValue
+ )
+ )
+ }
+ }
+
+ private fun addSystemSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system))
+ sl.apply {
+ add(
+ SwitchSetting(
+ IntSetting.USE_DOCKED_MODE,
+ R.string.use_docked_mode,
+ R.string.use_docked_mode_description,
+ IntSetting.USE_DOCKED_MODE.key,
+ IntSetting.USE_DOCKED_MODE.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.REGION_INDEX,
+ R.string.emulated_region,
+ 0,
+ R.array.regionNames,
+ R.array.regionValues,
+ IntSetting.REGION_INDEX.key,
+ IntSetting.REGION_INDEX.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.LANGUAGE_INDEX,
+ R.string.emulated_language,
+ 0,
+ R.array.languageNames,
+ R.array.languageValues,
+ IntSetting.LANGUAGE_INDEX.key,
+ IntSetting.LANGUAGE_INDEX.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ BooleanSetting.USE_CUSTOM_RTC,
+ R.string.use_custom_rtc,
+ R.string.use_custom_rtc_description,
+ BooleanSetting.USE_CUSTOM_RTC.key,
+ BooleanSetting.USE_CUSTOM_RTC.defaultValue
+ )
+ )
+ add(
+ DateTimeSetting(
+ StringSetting.CUSTOM_RTC,
+ R.string.set_custom_rtc,
+ 0,
+ StringSetting.CUSTOM_RTC.key,
+ StringSetting.CUSTOM_RTC.defaultValue
+ )
+ )
+ }
+ }
+
+ private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics))
+ sl.apply {
+
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_ACCURACY,
+ R.string.renderer_accuracy,
+ 0,
+ R.array.rendererAccuracyNames,
+ R.array.rendererAccuracyValues,
+ IntSetting.RENDERER_ACCURACY.key,
+ IntSetting.RENDERER_ACCURACY.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_RESOLUTION,
+ R.string.renderer_resolution,
+ 0,
+ R.array.rendererResolutionNames,
+ R.array.rendererResolutionValues,
+ IntSetting.RENDERER_RESOLUTION.key,
+ IntSetting.RENDERER_RESOLUTION.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_VSYNC,
+ R.string.renderer_vsync,
+ 0,
+ R.array.rendererVSyncNames,
+ R.array.rendererVSyncValues,
+ IntSetting.RENDERER_VSYNC.key,
+ IntSetting.RENDERER_VSYNC.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_SCALING_FILTER,
+ R.string.renderer_scaling_filter,
+ 0,
+ R.array.rendererScalingFilterNames,
+ R.array.rendererScalingFilterValues,
+ IntSetting.RENDERER_SCALING_FILTER.key,
+ IntSetting.RENDERER_SCALING_FILTER.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_ANTI_ALIASING,
+ R.string.renderer_anti_aliasing,
+ 0,
+ R.array.rendererAntiAliasingNames,
+ R.array.rendererAntiAliasingValues,
+ IntSetting.RENDERER_ANTI_ALIASING.key,
+ IntSetting.RENDERER_ANTI_ALIASING.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_ASPECT_RATIO,
+ R.string.renderer_aspect_ratio,
+ 0,
+ R.array.rendererAspectRatioNames,
+ R.array.rendererAspectRatioValues,
+ IntSetting.RENDERER_ASPECT_RATIO.key,
+ IntSetting.RENDERER_ASPECT_RATIO.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_USE_DISK_SHADER_CACHE,
+ R.string.use_disk_shader_cache,
+ R.string.use_disk_shader_cache_description,
+ IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key,
+ IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_FORCE_MAX_CLOCK,
+ R.string.renderer_force_max_clock,
+ R.string.renderer_force_max_clock_description,
+ IntSetting.RENDERER_FORCE_MAX_CLOCK.key,
+ IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_ASYNCHRONOUS_SHADERS,
+ R.string.renderer_asynchronous_shaders,
+ R.string.renderer_asynchronous_shaders_description,
+ IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key,
+ IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue
+ )
+ )
+ }
+ }
+
+ private fun addAudioSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio))
+ sl.add(
+ SliderSetting(
+ IntSetting.AUDIO_VOLUME,
+ R.string.audio_volume,
+ R.string.audio_volume_description,
+ 0,
+ 100,
+ "%",
+ IntSetting.AUDIO_VOLUME.key,
+ IntSetting.AUDIO_VOLUME.defaultValue
+ )
+ )
+ }
+
+ private fun addThemeSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme))
+ sl.apply {
+ val theme: AbstractIntSetting = object : AbstractIntSetting {
+ override var int: Int
+ get() = preferences.getInt(Settings.PREF_THEME, 0)
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_THEME, value)
+ .apply()
+ settingsActivity.recreate()
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getInt(Settings.PREF_THEME, 0).toString()
+ override val defaultValue: Any = 0
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(
+ SingleChoiceSetting(
+ theme,
+ R.string.change_app_theme,
+ 0,
+ R.array.themeEntriesA12,
+ R.array.themeValuesA12
+ )
+ )
+ } else {
+ add(
+ SingleChoiceSetting(
+ theme,
+ R.string.change_app_theme,
+ 0,
+ R.array.themeEntries,
+ R.array.themeValues
+ )
+ )
+ }
+
+ val themeMode: AbstractIntSetting = object : AbstractIntSetting {
+ override var int: Int
+ get() = preferences.getInt(Settings.PREF_THEME_MODE, -1)
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_THEME_MODE, value)
+ .apply()
+ ThemeHelper.setThemeMode(settingsActivity)
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString()
+ override val defaultValue: Any = -1
+ }
+
+ add(
+ SingleChoiceSetting(
+ themeMode,
+ R.string.change_theme_mode,
+ 0,
+ R.array.themeModeEntries,
+ R.array.themeModeValues
+ )
+ )
+
+ val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting {
+ override var boolean: Boolean
+ get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value)
+ .apply()
+ settingsActivity.recreate()
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ .toString()
+ override val defaultValue: Any = false
+ }
+
+ add(
+ SwitchSetting(
+ blackBackgrounds,
+ R.string.use_black_backgrounds,
+ R.string.use_black_backgrounds_description
+ )
+ )
+ }
+ }
+
+ private fun addDebugSettings(sl: ArrayList<SettingsItem>) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_debug))
+ sl.apply {
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_BACKEND,
+ R.string.renderer_api,
+ 0,
+ R.array.rendererApiNames,
+ R.array.rendererApiValues,
+ IntSetting.RENDERER_BACKEND.key,
+ IntSetting.RENDERER_BACKEND.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_DEBUG,
+ R.string.renderer_debug,
+ R.string.renderer_debug_description,
+ IntSetting.RENDERER_DEBUG.key,
+ IntSetting.RENDERER_DEBUG.defaultValue
+ )
+ )
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt
new file mode 100644
index 000000000..1ebe35eaa
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+
+/**
+ * Abstraction for a screen showing a list of settings. Instances of
+ * this type of view will each display a layer of the setting hierarchy.
+ */
+interface SettingsFragmentView {
+ /**
+ * Pass an ArrayList to the View so that it can be displayed on screen.
+ *
+ * @param settingsList The result of converting the HashMap to an ArrayList
+ */
+ fun showSettingsList(settingsList: ArrayList<SettingsItem>)
+
+ /**
+ * Instructs the Fragment to load the settings screen.
+ */
+ fun loadSettingsList()
+
+ /**
+ * @return The Fragment's containing activity.
+ */
+ val activityView: SettingsActivityView?
+
+ /**
+ * Tell the Fragment to tell the containing Activity to show a new
+ * Fragment containing a submenu of settings.
+ *
+ * @param menuKey Identifier for the settings group that should be shown.
+ */
+ fun loadSubMenu(menuKey: String)
+
+ /**
+ * Tell the Fragment to tell the containing activity to display a toast message.
+ *
+ * @param message Text to be shown in the Toast
+ * @param is_long Whether this should be a long Toast or short one.
+ */
+ fun showToastMessage(message: String?, is_long: Boolean)
+
+ /**
+ * Have the fragment add a setting to the HashMap.
+ *
+ * @param setting The (possibly previously missing) new setting.
+ */
+ fun putSetting(setting: AbstractSetting)
+
+ /**
+ * Have the fragment tell the containing Activity that a setting was modified.
+ */
+ fun onSettingChanged()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
new file mode 100644
index 000000000..04c045e77
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: DateTimeSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as DateTimeSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ val epochTime = setting.value.toLong()
+ val instant = Instant.ofEpochMilli(epochTime * 1000)
+ val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))
+ val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ binding.textSettingDescription.text = dateFormatter.format(zonedTime)
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ adapter.onDateTimeClick(setting, bindingAdapterPosition)
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt
new file mode 100644
index 000000000..f5bcf705c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ init {
+ itemView.setOnClickListener(null)
+ }
+
+ override fun bind(item: SettingsItem) {
+ binding.textHeaderName.setText(item.nameId)
+ }
+
+ override fun onClick(clicked: View) {
+ // no-op
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt
new file mode 100644
index 000000000..5dad5945f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.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.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: RunnableSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as RunnableSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (!setting.isRuntimeRunnable && !NativeLibrary.isRunning()) {
+ setting.runnable.invoke()
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
new file mode 100644
index 000000000..f56460893
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
+ RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
+
+ init {
+ itemView.setOnClickListener(this)
+ itemView.setOnLongClickListener(this)
+ }
+
+ /**
+ * Called by the adapter to set this ViewHolder's child views to display the list item
+ * it must now represent.
+ *
+ * @param item The list item that should be represented by this ViewHolder.
+ */
+ abstract fun bind(item: SettingsItem)
+
+ /**
+ * Called when this ViewHolder's view is clicked on. Implementations should usually pass
+ * this event up to the adapter.
+ *
+ * @param clicked The view that was clicked on.
+ */
+ abstract override fun onClick(clicked: View)
+
+ abstract override fun onLongClick(clicked: View): Boolean
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
new file mode 100644
index 000000000..de764a27f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: SettingsItem
+
+ override fun bind(item: SettingsItem) {
+ setting = item
+ binding.textSettingName.setText(item.nameId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ } else if (item is SingleChoiceSetting) {
+ val resMgr = binding.textSettingDescription.context.resources
+ val values = resMgr.getIntArray(item.valuesId)
+ for (i in values.indices) {
+ if (values[i] == item.selectedValue) {
+ binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i]
+ }
+ }
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (!setting.isEditable) {
+ return
+ }
+
+ if (setting is SingleChoiceSetting) {
+ adapter.onSingleChoiceClick(
+ (setting as SingleChoiceSetting),
+ bindingAdapterPosition
+ )
+ } else if (setting is StringSingleChoiceSetting) {
+ adapter.onStringSingleChoiceClick(
+ (setting as StringSingleChoiceSetting),
+ bindingAdapterPosition
+ )
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
new file mode 100644
index 000000000..cc3f39aa5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: SliderSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as SliderSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ adapter.onSliderClick(setting, bindingAdapterPosition)
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
new file mode 100644
index 000000000..c545b4174
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var item: SubmenuSetting
+
+ override fun bind(item: SettingsItem) {
+ this.item = item as SubmenuSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ adapter.onSubmenuClick(item)
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
new file mode 100644
index 000000000..b163bd6ca
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import android.widget.CompoundButton
+import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ private lateinit var setting: SwitchSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as SwitchSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.text = ""
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ binding.switchWidget.isChecked = setting.isChecked
+ binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
+ adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked)
+ }
+
+ binding.switchWidget.isEnabled = setting.isEditable
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ binding.switchWidget.toggle()
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
new file mode 100644
index 000000000..e29bca11d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
@@ -0,0 +1,241 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.utils
+
+import org.ini4j.Wini
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.*
+import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
+import org.yuzu.yuzu_emu.utils.BiMap
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.Log
+import java.io.*
+import java.util.*
+
+/**
+ * Contains static methods for interacting with .ini files in which settings are stored.
+ */
+object SettingsFile {
+ const val FILE_NAME_CONFIG = "config"
+
+ private var sectionsMap = BiMap<String?, String?>()
+
+ /**
+ * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves
+ * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
+ * failed.
+ *
+ * @param ini The ini file to load the settings from
+ * @param isCustomGame
+ * @param view The current view.
+ * @return An Observable that emits a HashMap of the file's contents, then completes.
+ */
+ private fun readFile(
+ ini: File?,
+ isCustomGame: Boolean,
+ view: SettingsActivityView? = null
+ ): HashMap<String, SettingSection?> {
+ val sections: HashMap<String, SettingSection?> = SettingsSectionMap()
+ var reader: BufferedReader? = null
+ try {
+ reader = BufferedReader(FileReader(ini))
+ var current: SettingSection? = null
+ var line: String?
+ while (reader.readLine().also { line = it } != null) {
+ if (line!!.startsWith("[") && line!!.endsWith("]")) {
+ current = sectionFromLine(line!!, isCustomGame)
+ sections[current.name] = current
+ } else if (current != null) {
+ val setting = settingFromLine(line!!)
+ if (setting != null) {
+ current.putSetting(setting)
+ }
+ }
+ }
+ } catch (e: FileNotFoundException) {
+ Log.error("[SettingsFile] File not found: " + e.message)
+ view?.onSettingsFileNotFound()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] Error reading from: " + e.message)
+ view?.onSettingsFileNotFound()
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] Error closing: " + e.message)
+ }
+ }
+ }
+ return sections
+ }
+
+ fun readFile(fileName: String, view: SettingsActivityView?): HashMap<String, SettingSection?> {
+ return readFile(getSettingsFile(fileName), false, view)
+ }
+
+ fun readFile(fileName: String): HashMap<String, SettingSection?> =
+ readFile(getSettingsFile(fileName), false)
+
+ /**
+ * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves
+ * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
+ * failed.
+ *
+ * @param gameId the id of the game to load it's settings.
+ * @param view The current view.
+ */
+ fun readCustomGameSettings(
+ gameId: String,
+ view: SettingsActivityView?
+ ): HashMap<String, SettingSection?> {
+ return readFile(getCustomGameSettingsFile(gameId), true, view)
+ }
+
+ /**
+ * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error
+ * telling why it failed.
+ *
+ * @param fileName The target filename without a path or extension.
+ * @param sections The HashMap containing the Settings we want to serialize.
+ * @param view The current view.
+ */
+ fun saveFile(
+ fileName: String,
+ sections: TreeMap<String, SettingSection>,
+ view: SettingsActivityView
+ ) {
+ val ini = getSettingsFile(fileName)
+ try {
+ val writer = Wini(ini)
+ val keySet: Set<String> = sections.keys
+ for (key in keySet) {
+ val section = sections[key]
+ writeSection(writer, section!!)
+ }
+ writer.store()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message)
+ view.showToastMessage(
+ YuzuApplication.appContext
+ .getString(R.string.error_saving, fileName, e.message),
+ false
+ )
+ }
+ }
+
+ fun saveCustomGameSettings(gameId: String?, sections: HashMap<String, SettingSection?>) {
+ val sortedSections: Set<String> = TreeSet(sections.keys)
+ for (sectionKey in sortedSections) {
+ val section = sections[sectionKey]
+ val settings = section!!.settings
+ val sortedKeySet: Set<String> = TreeSet(settings.keys)
+ for (settingKey in sortedKeySet) {
+ val setting = settings[settingKey]
+ NativeLibrary.setUserSetting(
+ gameId, mapSectionNameFromIni(
+ section.name
+ ), setting!!.key, setting.valueAsString
+ )
+ }
+ }
+ }
+
+ private fun mapSectionNameFromIni(generalSectionName: String): String? {
+ return if (sectionsMap.getForward(generalSectionName) != null) {
+ sectionsMap.getForward(generalSectionName)
+ } else generalSectionName
+ }
+
+ private fun mapSectionNameToIni(generalSectionName: String): String {
+ return if (sectionsMap.getBackward(generalSectionName) != null) {
+ sectionsMap.getBackward(generalSectionName).toString()
+ } else generalSectionName
+ }
+
+ fun getSettingsFile(fileName: String): File {
+ return File(
+ DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini"
+ )
+ }
+
+ private fun getCustomGameSettingsFile(gameId: String): File {
+ return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini")
+ }
+
+ private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
+ var sectionName: String = line.substring(1, line.length - 1)
+ if (isCustomGame) {
+ sectionName = mapSectionNameToIni(sectionName)
+ }
+ return SettingSection(sectionName)
+ }
+
+ /**
+ * For a line of text, determines what type of data is being represented, and returns
+ * a Setting object containing this data.
+ *
+ * @param line The line of text being parsed.
+ * @return A typed Setting containing the key/value contained in the line.
+ */
+ private fun settingFromLine(line: String): AbstractSetting? {
+ val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ if (splitLine.size != 2) {
+ return null
+ }
+ val key = splitLine[0].trim { it <= ' ' }
+ val value = splitLine[1].trim { it <= ' ' }
+ if (value.isEmpty()) {
+ return null
+ }
+
+ val booleanSetting = BooleanSetting.from(key)
+ if (booleanSetting != null) {
+ booleanSetting.boolean = value.toBoolean()
+ return booleanSetting
+ }
+
+ val intSetting = IntSetting.from(key)
+ if (intSetting != null) {
+ intSetting.int = value.toInt()
+ return intSetting
+ }
+
+ val floatSetting = FloatSetting.from(key)
+ if (floatSetting != null) {
+ floatSetting.float = value.toFloat()
+ return floatSetting
+ }
+
+ val stringSetting = StringSetting.from(key)
+ if (stringSetting != null) {
+ stringSetting.string = value
+ return stringSetting
+ }
+
+ return null
+ }
+
+ /**
+ * Writes the contents of a Section HashMap to disk.
+ *
+ * @param parser A Wini pointed at a file on disk.
+ * @param section A section containing settings to be written to the file.
+ */
+ private fun writeSection(parser: Wini, section: SettingSection) {
+ // Write the section header.
+ val header = section.name
+
+ // Write this section's values.
+ val settings = section.settings
+ val keySet: Set<String> = settings.keys
+ for (key in keySet) {
+ val setting = settings[key]
+ parser.put(header, setting!!.key, setting.valueAsString)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
new file mode 100644
index 000000000..c92e2755c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
@@ -0,0 +1,125 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import androidx.core.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
+
+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.textBuildHash.text = BuildConfig.GIT_HASH
+ binding.buttonBuildHash.setOnClickListener {
+ val clipBoard =
+ requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH)
+ clipBoard.setPrimaryClip(clip)
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Toast.makeText(
+ requireContext(),
+ R.string.copied_to_clipboard,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
+ binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
+ binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
+
+ setInsets()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarAbout.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.scrollAbout.layoutParams = mlpScrollAbout
+
+ binding.contentAbout.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt
new file mode 100644
index 000000000..d8bbc1ce4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+
+class EarlyAccessFragment : Fragment() {
+ private var _binding: FragmentEarlyAccessBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentEarlyAccessBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarAbout.setNavigationOnClickListener {
+ parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
+ }
+
+ binding.getEarlyAccessButton.setOnClickListener { openLink(getString(R.string.play_store_link)) }
+
+ setInsets()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarEa.layoutParams = mlpAppBar
+
+ binding.scrollEa.updatePadding(
+ left = leftInsets,
+ right = rightInsets,
+ bottom = barInsets.bottom
+ )
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
new file mode 100644
index 000000000..9523381cd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
@@ -0,0 +1,613 @@
+// 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.SharedPreferences
+import android.content.pm.ActivityInfo
+import android.content.res.Resources
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Rational
+import android.util.TypedValue
+import android.view.*
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.preference.PreferenceManager
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowLayoutInfo
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.slider.Slider
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding
+import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
+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.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.*
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+
+class EmulationFragment : Fragment(), SurfaceHolder.Callback {
+ private lateinit var preferences: SharedPreferences
+ private lateinit var emulationState: EmulationState
+ private var emulationActivity: EmulationActivity? = null
+ private var perfStatsUpdater: (() -> Unit)? = null
+
+ private var _binding: FragmentEmulationBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var game: Game
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ if (context is EmulationActivity) {
+ emulationActivity = context
+ NativeLibrary.setEmulationActivity(context)
+ } else {
+ throw IllegalStateException("EmulationFragment must have EmulationActivity parent")
+ }
+ }
+
+ /**
+ * Initialize anything that doesn't depend on the layout / views in here.
+ */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // So this fragment doesn't restart on configuration changes; i.e. rotation.
+ retainInstance = true
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!!
+ emulationState = EmulationState(game.path)
+ }
+
+ /**
+ * Initialize the UI and start emulation in here.
+ */
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentEmulationBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ binding.surfaceEmulation.holder.addCallback(this)
+ binding.showFpsText.setTextColor(Color.YELLOW)
+ binding.doneControlConfig.setOnClickListener { stopConfiguringControls() }
+
+ // Setup overlay.
+ updateShowFpsOverlay()
+
+ binding.inGameMenu.getHeaderView(0).findViewById<TextView>(R.id.text_game_title).text =
+ game.title
+ binding.inGameMenu.setNavigationItemSelectedListener {
+ when (it.itemId) {
+ R.id.menu_pause_emulation -> {
+ if (emulationState.isPaused) {
+ emulationState.run(false)
+ it.title = resources.getString(R.string.emulation_pause)
+ it.icon = ResourcesCompat.getDrawable(
+ resources,
+ R.drawable.ic_pause,
+ requireContext().theme
+ )
+ } else {
+ emulationState.pause()
+ it.title = resources.getString(R.string.emulation_unpause)
+ it.icon = ResourcesCompat.getDrawable(
+ resources,
+ R.drawable.ic_play,
+ requireContext().theme
+ )
+ }
+ true
+ }
+
+ R.id.menu_settings -> {
+ SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "")
+ true
+ }
+
+ R.id.menu_overlay_controls -> {
+ showOverlayOptions()
+ true
+ }
+
+ R.id.menu_exit -> {
+ emulationState.stop()
+ requireActivity().finish()
+ true
+ }
+
+ else -> true
+ }
+ }
+
+ setInsets()
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ requireActivity(),
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open()
+ }
+ })
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (!DirectoryInitialization.areDirectoriesReady) {
+ DirectoryInitialization.start(requireContext())
+ }
+
+ binding.surfaceEmulation.setAspectRatio(
+ when (IntSetting.RENDERER_ASPECT_RATIO.int) {
+ 0 -> Rational(16, 9)
+ 1 -> Rational(4, 3)
+ 2 -> Rational(21, 9)
+ 3 -> Rational(16, 10)
+ 4 -> null // Stretch
+ else -> Rational(16, 9)
+ }
+ )
+
+ emulationState.run(emulationActivity!!.isActivityRecreated)
+ }
+
+ override fun onPause() {
+ if (emulationState.isRunning) {
+ emulationState.pause()
+ }
+ super.onPause()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun onDetach() {
+ NativeLibrary.clearEmulationActivity()
+ super.onDetach()
+ }
+
+ private fun refreshInputOverlay() {
+ binding.surfaceInputOverlay.refreshControls()
+ }
+
+ private fun resetInputOverlay() {
+ preferences.edit()
+ .remove(Settings.PREF_CONTROL_SCALE)
+ .remove(Settings.PREF_CONTROL_OPACITY)
+ .apply()
+ binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() }
+ }
+
+ private fun updateShowFpsOverlay() {
+ if (EmulationMenuSettings.showFps) {
+ val SYSTEM_FPS = 0
+ val FPS = 1
+ val FRAMETIME = 2
+ val SPEED = 3
+ perfStatsUpdater = {
+ val perfStats = NativeLibrary.getPerfStats()
+ if (perfStats[FPS] > 0 && _binding != null) {
+ binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS])
+ }
+
+ if (!emulationState.isStopped) {
+ perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100)
+ }
+ }
+ perfStatsUpdateHandler.post(perfStatsUpdater!!)
+ binding.showFpsText.text = resources.getString(R.string.emulation_game_loading)
+ binding.showFpsText.visibility = View.VISIBLE
+ } else {
+ if (perfStatsUpdater != null) {
+ perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!)
+ }
+ binding.showFpsText.visibility = View.GONE
+ }
+ }
+
+ private val Number.toPx get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics).toInt()
+
+ fun updateCurrentLayout(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) {
+ binding.surfaceEmulation.layoutParams.height = it.bounds.top
+ binding.inGameMenu.layoutParams.height = it.bounds.bottom
+ binding.overlayContainer.layoutParams.height = it.bounds.bottom - 48.toPx
+ binding.overlayContainer.updatePadding(0, 0, 0, 24.toPx)
+ }
+ }
+ it.isSeparating
+ } ?: false
+ if (!isFolding) {
+ binding.surfaceEmulation.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
+ binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
+ binding.overlayContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
+ binding.overlayContainer.updatePadding(0, 0, 0, 0)
+ emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
+ }
+ binding.surfaceInputOverlay.requestLayout()
+ binding.inGameMenu.requestLayout()
+ binding.overlayContainer.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<View>(R.id.menu_overlay_controls)
+ val popup = PopupMenu(requireContext(), anchor)
+
+ popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu)
+
+ popup.menu.apply {
+ findItem(R.id.menu_toggle_fps).isChecked = EmulationMenuSettings.showFps
+ findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter
+ findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide
+ findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay
+ findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback
+ }
+
+ popup.setOnMenuItemClickListener {
+ when (it.itemId) {
+ R.id.menu_toggle_fps -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.showFps = it.isChecked
+ updateShowFpsOverlay()
+ true
+ }
+
+ R.id.menu_edit_overlay -> {
+ binding.drawerLayout.close()
+ binding.surfaceInputOverlay.requestFocus()
+ startConfiguringControls()
+ true
+ }
+
+ R.id.menu_adjust_overlay -> {
+ adjustOverlay()
+ true
+ }
+
+ R.id.menu_toggle_controls -> {
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ val optionsArray = BooleanArray(15)
+ for (i in 0..14) {
+ optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13)
+ }
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.emulation_toggle_controls)
+ .setMultiChoiceItems(
+ R.array.gamepadButtons,
+ optionsArray
+ ) { _, indexSelected, isChecked ->
+ preferences.edit()
+ .putBoolean("buttonToggle$indexSelected", isChecked)
+ .apply()
+ }
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ refreshInputOverlay()
+ }
+ .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]
+ for (i in 0..14) {
+ optionsArray[i] = isChecked
+ dialog.listView.setItemChecked(i, isChecked)
+ preferences.edit()
+ .putBoolean("buttonToggle$i", isChecked)
+ .apply()
+ }
+ }
+ true
+ }
+
+ R.id.menu_show_overlay -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.showOverlay = it.isChecked
+ refreshInputOverlay()
+ true
+ }
+
+ R.id.menu_rel_stick_center -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.joystickRelCenter = it.isChecked
+ true
+ }
+
+ R.id.menu_dpad_slide -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.dpadSlide = it.isChecked
+ true
+ }
+
+ R.id.menu_haptics -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.hapticFeedback = it.isChecked
+ true
+ }
+
+ R.id.menu_reset_overlay -> {
+ binding.drawerLayout.close()
+ resetInputOverlay()
+ true
+ }
+
+ else -> true
+ }
+ }
+
+ popup.show()
+ }
+
+ private fun startConfiguringControls() {
+ binding.doneControlConfig.visibility = View.VISIBLE
+ binding.surfaceInputOverlay.setIsInEditMode(true)
+ }
+
+ private fun stopConfiguringControls() {
+ binding.doneControlConfig.visibility = View.GONE
+ binding.surfaceInputOverlay.setIsInEditMode(false)
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun adjustOverlay() {
+ val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater)
+ adjustBinding.apply {
+ inputScaleSlider.apply {
+ valueTo = 150F
+ value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat()
+ addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
+ inputScaleValue.text = "${value.toInt()}%"
+ setControlScale(value.toInt())
+ })
+ }
+ inputOpacitySlider.apply {
+ valueTo = 100F
+ value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).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, null)
+ .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int ->
+ setControlScale(50)
+ setControlOpacity(100)
+ }
+ .show()
+ }
+
+ private fun setControlScale(scale: Int) {
+ preferences.edit()
+ .putInt(Settings.PREF_CONTROL_SCALE, scale)
+ .apply()
+ refreshInputOverlay()
+ }
+
+ private fun setControlOpacity(opacity: Int) {
+ preferences.edit()
+ .putInt(Settings.PREF_CONTROL_OPACITY, opacity)
+ .apply()
+ refreshInputOverlay()
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.inGameMenu) { v: View, windowInsets: WindowInsetsCompat ->
+ val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ var left = 0
+ var right = 0
+ if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ left = cutInsets.left
+ } else {
+ right = cutInsets.right
+ }
+
+ v.setPadding(left, cutInsets.top, right, 0)
+
+ // Ensure FPS text doesn't get cut off by rounded display corners
+ val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
+ if (cutInsets.left == 0) {
+ binding.showFpsText.setPadding(
+ sidePadding,
+ cutInsets.top,
+ cutInsets.right,
+ cutInsets.bottom
+ )
+ } else {
+ binding.showFpsText.setPadding(
+ cutInsets.left,
+ cutInsets.top,
+ cutInsets.right,
+ cutInsets.bottom
+ )
+ }
+ windowInsets
+ }
+ }
+
+ private class EmulationState(private val gamePath: String) {
+ private var state: State
+ private var surface: Surface? = null
+ private var runWhenSurfaceIsValid = false
+
+ init {
+ // Starting state is stopped.
+ state = State.STOPPED
+ }
+
+ @get:Synchronized
+ val isStopped: Boolean
+ get() = state == State.STOPPED
+
+ // Getters for the current state
+ @get:Synchronized
+ val isPaused: Boolean
+ get() = state == State.PAUSED
+
+ @get:Synchronized
+ val isRunning: Boolean
+ get() = state == State.RUNNING
+
+ @Synchronized
+ fun stop() {
+ if (state != State.STOPPED) {
+ Log.debug("[EmulationFragment] Stopping emulation.")
+ NativeLibrary.stopEmulation()
+ state = State.STOPPED
+ } else {
+ Log.warning("[EmulationFragment] Stop called while already stopped.")
+ }
+ }
+
+ // State changing methods
+ @Synchronized
+ fun pause() {
+ if (state != State.PAUSED) {
+ Log.debug("[EmulationFragment] Pausing emulation.")
+
+ NativeLibrary.pauseEmulation()
+
+ state = State.PAUSED
+ } else {
+ Log.warning("[EmulationFragment] Pause called while already paused.")
+ }
+ }
+
+ @Synchronized
+ fun run(isActivityRecreated: Boolean) {
+ if (isActivityRecreated) {
+ if (NativeLibrary.isRunning()) {
+ state = State.PAUSED
+ }
+ } else {
+ Log.debug("[EmulationFragment] activity resumed or fresh start")
+ }
+
+ // If the surface is set, run now. Otherwise, wait for it to get set.
+ if (surface != null) {
+ runWithValidSurface()
+ } else {
+ runWhenSurfaceIsValid = true
+ }
+ }
+
+ // Surface callbacks
+ @Synchronized
+ fun newSurface(surface: Surface?) {
+ this.surface = surface
+ if (runWhenSurfaceIsValid) {
+ runWithValidSurface()
+ }
+ }
+
+ @Synchronized
+ fun clearSurface() {
+ if (surface == null) {
+ Log.warning("[EmulationFragment] clearSurface called, but surface already null.")
+ } else {
+ surface = null
+ Log.debug("[EmulationFragment] Surface destroyed.")
+ when (state) {
+ State.RUNNING -> {
+ state = State.PAUSED
+ }
+
+ State.PAUSED -> Log.warning("[EmulationFragment] Surface cleared while emulation paused.")
+ else -> Log.warning("[EmulationFragment] Surface cleared while emulation stopped.")
+ }
+ }
+ }
+
+ private fun runWithValidSurface() {
+ runWhenSurfaceIsValid = false
+ when (state) {
+ State.STOPPED -> {
+ NativeLibrary.surfaceChanged(surface)
+ val emulationThread = Thread({
+ Log.debug("[EmulationFragment] Starting emulation thread.")
+ NativeLibrary.run(gamePath)
+ }, "NativeEmulation")
+ emulationThread.start()
+ }
+
+ State.PAUSED -> {
+ Log.debug("[EmulationFragment] Resuming emulation.")
+ NativeLibrary.surfaceChanged(surface)
+ NativeLibrary.unPauseEmulation()
+ }
+
+ else -> Log.debug("[EmulationFragment] Bug, run called while already running.")
+ }
+ state = State.RUNNING
+ }
+
+ private enum class State {
+ STOPPED, RUNNING, PAUSED
+ }
+ }
+
+ companion object {
+ private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!)
+
+ fun newInstance(game: Game): EmulationFragment {
+ val args = Bundle()
+ args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game)
+ val fragment = EmulationFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
new file mode 100644
index 000000000..bdc337501
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -0,0 +1,330 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.Manifest
+import android.content.ActivityNotFoundException
+import android.content.DialogInterface
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.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.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.BuildConfig
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding
+import org.yuzu.yuzu_emu.features.DocumentProvider
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.model.HomeSetting
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+
+class HomeSettingsFragment : Fragment() {
+ private var _binding: FragmentHomeSettingsBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var mainActivity: MainActivity
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentHomeSettingsBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ val optionsList: MutableList<HomeSetting> = mutableListOf(
+ HomeSetting(
+ R.string.advanced_settings,
+ R.string.settings_description,
+ R.drawable.ic_settings
+ ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") },
+ HomeSetting(
+ R.string.open_user_folder,
+ R.string.open_user_folder_description,
+ R.drawable.ic_folder_open
+ ) { openFileManager() },
+ HomeSetting(
+ R.string.preferences_theme,
+ R.string.theme_and_color_description,
+ R.drawable.ic_palette
+ ) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") },
+ HomeSetting(
+ R.string.install_gpu_driver,
+ R.string.install_gpu_driver_description,
+ R.drawable.ic_exit
+ ) { driverInstaller() },
+ HomeSetting(
+ R.string.install_amiibo_keys,
+ R.string.install_amiibo_keys_description,
+ R.drawable.ic_nfc
+ ) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) },
+ HomeSetting(
+ R.string.select_games_folder,
+ R.string.select_games_folder_description,
+ R.drawable.ic_add
+ ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+ HomeSetting(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ R.drawable.ic_save
+ ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) },
+ HomeSetting(
+ R.string.install_prod_keys,
+ R.string.install_prod_keys_description,
+ R.drawable.ic_unlock
+ ) { mainActivity.getProdKey.launch(arrayOf("*/*")) },
+ HomeSetting(
+ R.string.install_firmware,
+ R.string.install_firmware_description,
+ R.drawable.ic_firmware
+ ) { mainActivity.getFirmware.launch(arrayOf("application/zip")) },
+ HomeSetting(
+ R.string.share_log,
+ R.string.share_log_description,
+ R.drawable.ic_log
+ ) { shareLog() },
+ HomeSetting(
+ R.string.about,
+ R.string.about_description,
+ R.drawable.ic_info_outline
+ ) {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment)
+ }
+ )
+
+ if (!BuildConfig.PREMIUM) {
+ optionsList.add(
+ 0,
+ HomeSetting(
+ R.string.get_early_access,
+ R.string.get_early_access_description,
+ R.drawable.ic_diamond
+ ) {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment)
+ }
+ )
+ }
+
+ binding.homeSettingsList.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList)
+ }
+
+ setInsets()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ exitTransition = null
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun openFileManager() {
+ // First, try to open the user data folder directly
+ try {
+ startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW))
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ try {
+ startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE"))
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ // Just try to open the file manager, try the package name used on "normal" phones
+ try {
+ startActivity(getFileManagerIntent("com.google.android.documentsui"))
+ showNoLinkNotification()
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ try {
+ // Next, try the AOSP package name
+ startActivity(getFileManagerIntent("com.android.documentsui"))
+ showNoLinkNotification()
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ Toast.makeText(
+ requireContext(),
+ resources.getString(R.string.no_file_manager),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ private fun getFileManagerIntent(packageName: String): Intent {
+ // Fragile, but some phones don't expose the system file manager in any better way
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity")
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ return intent
+ }
+
+ private fun getFileManagerIntentOnDocumentProvider(action: String): Intent {
+ val authority = "${requireContext().packageName}.user"
+ val intent = Intent(action)
+ intent.addCategory(Intent.CATEGORY_DEFAULT)
+ intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID)
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ return intent
+ }
+
+ private fun showNoLinkNotification() {
+ val builder = NotificationCompat.Builder(
+ requireContext(),
+ getString(R.string.notice_notification_channel_id)
+ )
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.notification_no_directory_link))
+ .setContentText(getString(R.string.notification_no_directory_link_description))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ // TODO: Make the click action for this notification lead to a help article
+
+ with(NotificationManagerCompat.from(requireContext())) {
+ if (ActivityCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ Toast.makeText(
+ requireContext(),
+ resources.getString(R.string.notification_permission_not_granted),
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ notify(0, builder.build())
+ }
+ }
+
+ private fun driverInstaller() {
+ // Get the driver name for the dialog message.
+ var driverName = GpuDriverHelper.customDriverName
+ if (driverName == null) {
+ driverName = getString(R.string.system_gpu_driver)
+ }
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(getString(R.string.select_gpu_driver_title))
+ .setMessage(driverName)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
+ GpuDriverHelper.installDefaultDriver(requireContext())
+ Toast.makeText(
+ requireContext(),
+ R.string.select_gpu_driver_use_default,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
+ mainActivity.getDriver.launch(arrayOf("application/zip"))
+ }
+ .show()
+ }
+
+ private fun shareLog() {
+ val file = DocumentFile.fromSingleUri(
+ mainActivity,
+ DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt"
+ )
+ )!!
+ if (file.exists()) {
+ val intent = Intent(Intent.ACTION_SEND)
+ .setDataAndType(file.uri, FileUtil.TEXT_PLAIN)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(Intent.EXTRA_STREAM, file.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
+ )
+
+ val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams
+ mlpScrollSettings.leftMargin = leftInsets
+ mlpScrollSettings.rightMargin = rightInsets
+ binding.scrollViewSettings.layoutParams = mlpScrollSettings
+
+ binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation)
+
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail)
+ } else {
+ binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail)
+ }
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
new file mode 100644
index 000000000..36e63bb9e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
@@ -0,0 +1,210 @@
+// 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.provider.DocumentsContract
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+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.DocumentProvider
+import org.yuzu.yuzu_emu.getPublicFilesDir
+import org.yuzu.yuzu_emu.utils.FileUtil
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.FilenameFilter
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+class ImportExportSavesFragment : DialogFragment() {
+ private val context = YuzuApplication.appContext
+ private val savesFolder =
+ "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
+
+ // Get first subfolder in saves folder (should be the user folder)
+ private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
+ private var lastZipCreated: File? = null
+
+ private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
+ private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val activity = requireActivity() as AppCompatActivity
+
+ val activityResultRegistry = requireActivity().activityResultRegistry
+ startForResultExportSave = activityResultRegistry.register(
+ "startForResultExportSaveKey",
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
+ }
+ documentPicker = activityResultRegistry.register(
+ "documentPickerKey",
+ ActivityResultContracts.OpenDocument()
+ ) {
+ it?.let { uri -> importSave(uri, activity) }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return if (savesFolderRoot == "") {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.manage_save_data)
+ .setMessage(R.string.import_export_saves_no_profile)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ } else {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.manage_save_data)
+ .setMessage(R.string.manage_save_data_description)
+ .setNegativeButton(R.string.export_saves) { _, _ ->
+ exportSave()
+ }
+ .setPositiveButton(R.string.import_saves) { _, _ ->
+ documentPicker.launch(arrayOf("application/zip"))
+ }
+ .setNeutralButton(android.R.string.cancel, null)
+ .show()
+ }
+ }
+
+ /**
+ * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
+ * @return true if the zip file is successfully created, false otherwise.
+ */
+ private fun zipSave(): Boolean {
+ try {
+ val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
+ tempFolder.mkdirs()
+ val saveFolder = File(savesFolderRoot)
+ val outputZipFile = File(
+ tempFolder,
+ "yuzu saves - ${
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ }.zip"
+ )
+ outputZipFile.createNewFile()
+ ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
+ saveFolder.walkTopDown().forEach { file ->
+ val zipFileName =
+ file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
+ if (zipFileName == "")
+ return@forEach
+ val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
+ zos.putNextEntry(entry)
+ if (file.isFile)
+ file.inputStream().use { fis -> fis.copyTo(zos) }
+ }
+ }
+ lastZipCreated = outputZipFile
+ } catch (e: Exception) {
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
+ */
+ private fun exportSave() {
+ CoroutineScope(Dispatchers.IO).launch {
+ val wasZipCreated = zipSave()
+ val lastZipFile = lastZipCreated
+ if (!wasZipCreated || lastZipFile == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ val file = DocumentFile.fromSingleUri(
+ context, DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
+ )
+ )!!
+ val intent = Intent(Intent.ACTION_SEND)
+ .setDataAndType(file.uri, "application/zip")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(Intent.EXTRA_STREAM, file.uri)
+ startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
+ }
+ }
+ }
+
+ /**
+ * Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
+ * @param zipUri The Uri of the zip file containing the save file(s) to import.
+ */
+ private fun importSave(zipUri: Uri, activity: AppCompatActivity) {
+ val inputZip = context.contentResolver.openInputStream(zipUri)
+ // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
+ var validZip = false
+ val savesFolder = File(savesFolderRoot)
+ val cacheSaveDir = File("${context.cacheDir.path}/saves/")
+ cacheSaveDir.mkdir()
+
+ if (inputZip == null) {
+ Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
+ .show()
+ return
+ }
+
+ val filterTitleId =
+ FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
+
+ try {
+ CoroutineScope(Dispatchers.IO).launch {
+ FileUtil.unzip(inputZip, cacheSaveDir)
+ cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
+ File(savesFolder, savePath).deleteRecursively()
+ File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
+ validZip = true
+ }
+
+ withContext(Dispatchers.Main) {
+ if (!validZip) {
+ MessageDialogFragment.newInstance(
+ R.string.save_file_invalid_zip_structure,
+ R.string.save_file_invalid_zip_structure_description
+ ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
+ return@withContext
+ }
+ Toast.makeText(
+ context,
+ context.getString(R.string.save_file_imported_success),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ cacheSaveDir.deleteRecursively()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
+ .show()
+ }
+ }
+
+ companion object {
+ const val TAG = "ImportExportSavesFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
new file mode 100644
index 000000000..c7880d8cc
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -0,0 +1,70 @@
+// 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.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.model.TaskViewModel
+
+
+class IndeterminateProgressDialogFragment : DialogFragment() {
+ private val taskViewModel: TaskViewModel by activityViewModels()
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val titleId = requireArguments().getInt(TITLE)
+
+ val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
+ progressBinding.progressBar.isIndeterminate = true
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(titleId)
+ .setView(progressBinding.root)
+ .create()
+ dialog.setCanceledOnTouchOutside(false)
+
+ taskViewModel.isComplete.observe(this) { complete ->
+ if (complete) {
+ dialog.dismiss()
+ when (val result = taskViewModel.result.value) {
+ is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show()
+ is MessageDialogFragment -> result.show(
+ parentFragmentManager,
+ MessageDialogFragment.TAG
+ )
+ }
+ taskViewModel.clear()
+ }
+ }
+
+ if (taskViewModel.isRunning.value == false) {
+ taskViewModel.runTask()
+ }
+ return dialog
+ }
+
+ companion object {
+ const val TAG = "IndeterminateProgressDialogFragment"
+
+ private const val TITLE = "Title"
+
+ fun newInstance(
+ activity: AppCompatActivity,
+ titleId: Int,
+ task: () -> Any
+ ): IndeterminateProgressDialogFragment {
+ val dialog = IndeterminateProgressDialogFragment()
+ val args = Bundle()
+ ViewModelProvider(activity)[TaskViewModel::class.java].task = task
+ args.putInt(TITLE, titleId)
+ dialog.arguments = args
+ return dialog
+ }
+ }
+}
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
new file mode 100644
index 000000000..78419191c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/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>(view.parent as View).state =
+ BottomSheetBehavior.STATE_HALF_EXPANDED
+
+ val license = requireArguments().parcelable<License>(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
new file mode 100644
index 000000000..59141e823
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt
@@ -0,0 +1,137 @@
+// 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.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.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
+
+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
+
+ val mlpAppBar = binding.appbarLicenses.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarLicenses.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.listLicenses.layoutParams = mlpScrollAbout
+
+ binding.listLicenses.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
new file mode 100644
index 000000000..2db38fdc2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
@@ -0,0 +1,62 @@
+// 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 androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class MessageDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val titleId = requireArguments().getInt(TITLE)
+ val descriptionId = requireArguments().getInt(DESCRIPTION)
+ val helpLinkId = requireArguments().getInt(HELP_LINK)
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.close, null)
+ .setTitle(titleId)
+ .setMessage(descriptionId)
+
+ if (helpLinkId != 0) {
+ dialog.setNeutralButton(R.string.learn_more) { _, _ ->
+ openLink(getString(helpLinkId))
+ }
+ }
+
+ return dialog.show()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ companion object {
+ const val TAG = "MessageDialogFragment"
+
+ private const val TITLE = "Title"
+ private const val DESCRIPTION = "Description"
+ private const val HELP_LINK = "Link"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int = 0
+ ): MessageDialogFragment {
+ val dialog = MessageDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE, titleId)
+ putInt(DESCRIPTION, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ }
+ 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
new file mode 100644
index 000000000..3478b9250
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class PermissionDeniedDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int ->
+ openSettings()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .setTitle(R.string.permission_denied)
+ .setMessage(R.string.permission_denied_description)
+ .show()
+ }
+
+ private fun openSettings() {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ val uri = Uri.fromParts("package", requireActivity().packageName, null)
+ intent.data = uri
+ startActivity(intent)
+ }
+
+ companion object {
+ const val TAG = "PermissionDeniedDialogFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt
new file mode 100644
index 000000000..1b4b93ab8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+
+class ResetSettingsDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val settingsActivity = requireActivity() as SettingsActivity
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.reset_all_settings)
+ .setMessage(R.string.reset_all_settings_description)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ settingsActivity.onSettingsReset()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ companion object {
+ const val TAG = "ResetSettingsDialogFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
new file mode 100644
index 000000000..ebc0f164a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
@@ -0,0 +1,236 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.preference.PreferenceManager
+import info.debatty.java.stringsimilarity.Jaccard
+import info.debatty.java.stringsimilarity.JaroWinkler
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.GameAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding
+import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.Log
+import java.util.Locale
+
+class SearchFragment : Fragment() {
+ private var _binding: FragmentSearchBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private lateinit var preferences: SharedPreferences
+
+ companion object {
+ private const val SEARCH_TEXT = "SearchText"
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSearchBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = false)
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ if (savedInstanceState != null) {
+ binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
+ }
+
+ binding.gridGamesSearch.apply {
+ layoutManager = AutofitGridLayoutManager(
+ requireContext(),
+ requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
+
+ binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
+ if (text.toString().isNotEmpty()) {
+ binding.clearButton.visibility = View.VISIBLE
+ } else {
+ binding.clearButton.visibility = View.INVISIBLE
+ }
+ filterAndSearch()
+ }
+
+ gamesViewModel.apply {
+ searchFocused.observe(viewLifecycleOwner) { searchFocused ->
+ if (searchFocused) {
+ focusSearch()
+ gamesViewModel.setSearchFocused(false)
+ }
+ }
+
+ games.observe(viewLifecycleOwner) { filterAndSearch() }
+ searchedGames.observe(viewLifecycleOwner) {
+ (binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
+ if (it.isEmpty()) {
+ binding.noResultsView.visibility = View.VISIBLE
+ } else {
+ binding.noResultsView.visibility = View.GONE
+ }
+ }
+ }
+
+ binding.clearButton.setOnClickListener { binding.searchText.setText("") }
+
+ binding.searchBackground.setOnClickListener { focusSearch() }
+
+ setInsets()
+ filterAndSearch()
+ }
+
+ private inner class ScoredGame(val score: Double, val item: Game)
+
+ private fun filterAndSearch() {
+ val baseList = gamesViewModel.games.value!!
+ val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) {
+ R.id.chip_recently_played -> {
+ baseList.filter {
+ val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
+ lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
+ }
+ }
+
+ R.id.chip_recently_added -> {
+ baseList.filter {
+ val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
+ addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
+ }
+ }
+
+ R.id.chip_homebrew -> {
+ baseList.filter {
+ Log.error("Guh - ${it.path}")
+ FileUtil.hasExtension(it.path, "nro")
+ || FileUtil.hasExtension(it.path, "nso")
+ }
+ }
+
+ R.id.chip_retail -> baseList.filter {
+ FileUtil.hasExtension(it.path, "xci")
+ || FileUtil.hasExtension(it.path, "nsp")
+ }
+
+ else -> baseList
+ }
+
+ if (binding.searchText.text.toString().isEmpty()
+ && binding.chipGroup.checkedChipId != View.NO_ID
+ ) {
+ gamesViewModel.setSearchedGames(filteredList)
+ return
+ }
+
+ val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
+ val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
+ val sortedList: List<Game> = filteredList.mapNotNull { game ->
+ val title = game.title.lowercase(Locale.getDefault())
+ val score = searchAlgorithm.similarity(searchTerm, title)
+ if (score > 0.03) {
+ ScoredGame(score, game)
+ } else {
+ null
+ }
+ }.sortedByDescending { it.score }.map { it.item }
+ gamesViewModel.setSearchedGames(sortedList)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ if (_binding != null) {
+ outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
+ }
+ }
+
+ private fun focusSearch() {
+ if (_binding != null) {
+ binding.searchText.requestFocus()
+ val imm =
+ requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
+ imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+ val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
+
+ binding.constraintSearch.updatePadding(
+ left = barInsets.left + cutoutInsets.left,
+ top = barInsets.top,
+ right = barInsets.right + cutoutInsets.right
+ )
+
+ binding.gridGamesSearch.updatePadding(
+ top = extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+ binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
+
+ val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.frameSearch.updatePadding(left = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
+ binding.noResultsView.updatePadding(left = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing + spacingNavigationRail,
+ right = chipSpacing
+ )
+ mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
+ mlpDivider.rightMargin = chipSpacing
+ } else {
+ binding.frameSearch.updatePadding(right = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
+ binding.noResultsView.updatePadding(right = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing,
+ right = chipSpacing + spacingNavigationRail
+ )
+ mlpDivider.leftMargin = chipSpacing
+ mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
+ }
+ binding.divider.layoutParams = mlpDivider
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
new file mode 100644
index 000000000..258773380
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
@@ -0,0 +1,329 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.preference.PreferenceManager
+import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
+import com.google.android.material.transition.MaterialFadeThrough
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.SetupAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.SetupPage
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.GameHelper
+import java.io.File
+
+class SetupFragment : Fragment() {
+ private var _binding: FragmentSetupBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private lateinit var mainActivity: MainActivity
+
+ private lateinit var hasBeenWarned: BooleanArray
+
+ companion object {
+ const val KEY_NEXT_VISIBILITY = "NextButtonVisibility"
+ const val KEY_BACK_VISIBILITY = "BackButtonVisibility"
+ const val KEY_HAS_BEEN_WARNED = "HasBeenWarned"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ exitTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSetupBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ homeViewModel.setNavigationVisibility(visible = false, animated = false)
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.viewPager2.currentItem > 0) {
+ pageBackward()
+ } else {
+ requireActivity().finish()
+ }
+ }
+ })
+
+ requireActivity().window.navigationBarColor =
+ ContextCompat.getColor(requireContext(), android.R.color.transparent)
+
+ val pages = mutableListOf<SetupPage>()
+ pages.apply {
+ add(
+ SetupPage(
+ R.drawable.ic_yuzu_title,
+ R.string.welcome,
+ R.string.welcome_description,
+ 0,
+ true,
+ R.string.get_started,
+ { pageForward() },
+ false
+ )
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(
+ SetupPage(
+ R.drawable.ic_notification,
+ R.string.notifications,
+ R.string.notifications_description,
+ 0,
+ false,
+ R.string.give_permission,
+ { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
+ true,
+ R.string.notification_warning,
+ R.string.notification_warning_description,
+ 0,
+ {
+ NotificationManagerCompat.from(requireContext())
+ .areNotificationsEnabled()
+ }
+ )
+ )
+ }
+
+ add(
+ SetupPage(
+ R.drawable.ic_key,
+ R.string.keys,
+ R.string.keys_description,
+ R.drawable.ic_add,
+ true,
+ R.string.select_keys,
+ { mainActivity.getProdKey.launch(arrayOf("*/*")) },
+ true,
+ R.string.install_prod_keys_warning,
+ R.string.install_prod_keys_warning_description,
+ R.string.install_prod_keys_warning_help,
+ { File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_controller,
+ R.string.games,
+ R.string.games_description,
+ R.drawable.ic_add,
+ true,
+ R.string.add_games,
+ { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+ true,
+ R.string.add_games_warning,
+ R.string.add_games_warning_description,
+ R.string.add_games_warning_help,
+ {
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()
+ }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_check,
+ R.string.done,
+ R.string.done_description,
+ R.drawable.ic_arrow_forward,
+ false,
+ R.string.text_continue,
+ { finishSetup() },
+ false
+ )
+ )
+ }
+
+ binding.viewPager2.apply {
+ adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
+ offscreenPageLimit = 2
+ isUserInputEnabled = false
+ }
+
+ binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() {
+ var previousPosition: Int = 0
+
+ override fun onPageSelected(position: Int) {
+ super.onPageSelected(position)
+
+ if (position == 1 && previousPosition == 0) {
+ showView(binding.buttonNext)
+ showView(binding.buttonBack)
+ } else if (position == 0 && previousPosition == 1) {
+ hideView(binding.buttonBack)
+ hideView(binding.buttonNext)
+ } else if (position == pages.size - 1 && previousPosition == pages.size - 2) {
+ hideView(binding.buttonNext)
+ } else if (position == pages.size - 2 && previousPosition == pages.size - 1) {
+ showView(binding.buttonNext)
+ }
+
+ previousPosition = position
+ }
+ })
+
+ binding.buttonNext.setOnClickListener {
+ val index = binding.viewPager2.currentItem
+ val currentPage = pages[index]
+
+ // Checks if the user has completed the task on the current page
+ if (currentPage.hasWarning) {
+ if (currentPage.taskCompleted.invoke()) {
+ pageForward()
+ return@setOnClickListener
+ }
+
+ if (!hasBeenWarned[index]) {
+ SetupWarningDialogFragment.newInstance(
+ currentPage.warningTitleId,
+ currentPage.warningDescriptionId,
+ currentPage.warningHelpLinkId,
+ index
+ ).show(childFragmentManager, SetupWarningDialogFragment.TAG)
+ return@setOnClickListener
+ }
+ }
+ pageForward()
+ }
+ binding.buttonBack.setOnClickListener { pageBackward() }
+
+ if (savedInstanceState != null) {
+ val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY)
+ val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY)
+ hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!!
+
+ if (nextIsVisible) {
+ binding.buttonNext.visibility = View.VISIBLE
+ }
+ if (backIsVisible) {
+ binding.buttonBack.visibility = View.VISIBLE
+ }
+ } else {
+ hasBeenWarned = BooleanArray(pages.size)
+ }
+
+ setInsets()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
+ outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
+ outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private val permissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
+ PermissionDeniedDialogFragment().show(
+ childFragmentManager,
+ PermissionDeniedDialogFragment.TAG
+ )
+ }
+ }
+
+ private fun finishSetup() {
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit()
+ .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
+ .apply()
+ mainActivity.finishSetup(binding.root.findNavController())
+ }
+
+ private fun showView(view: View) {
+ view.apply {
+ alpha = 0f
+ visibility = View.VISIBLE
+ isClickable = true
+ }.animate().apply {
+ duration = 300
+ alpha(1f)
+ }.start()
+ }
+
+ private fun hideView(view: View) {
+ if (view.visibility == View.INVISIBLE) {
+ return
+ }
+
+ view.apply {
+ alpha = 1f
+ isClickable = false
+ }.animate().apply {
+ duration = 300
+ alpha(0f)
+ }.withEndAction {
+ view.visibility = View.INVISIBLE
+ }
+ }
+
+ fun pageForward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1
+ }
+
+ fun pageBackward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1
+ }
+
+ fun setPageWarned(page: Int) {
+ hasBeenWarned[page] = true
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ view.setPadding(
+ barInsets.left + cutoutInsets.left,
+ barInsets.top + cutoutInsets.top,
+ barInsets.right + cutoutInsets.right,
+ barInsets.bottom + cutoutInsets.bottom
+ )
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt
new file mode 100644
index 000000000..b2c1d54af
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class SetupWarningDialogFragment : DialogFragment() {
+ private var titleId: Int = 0
+ private var descriptionId: Int = 0
+ private var helpLinkId: Int = 0
+ private var page: Int = 0
+
+ private lateinit var setupFragment: SetupFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ titleId = requireArguments().getInt(TITLE)
+ descriptionId = requireArguments().getInt(DESCRIPTION)
+ helpLinkId = requireArguments().getInt(HELP_LINK)
+ page = requireArguments().getInt(PAGE)
+
+ setupFragment = requireParentFragment() as SetupFragment
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
+ setupFragment.pageForward()
+ setupFragment.setPageWarned(page)
+ }
+ .setNegativeButton(R.string.warning_cancel, null)
+
+ if (titleId != 0) {
+ builder.setTitle(titleId)
+ } else {
+ builder.setTitle("")
+ }
+ if (descriptionId != 0) {
+ builder.setMessage(descriptionId)
+ }
+ if (helpLinkId != 0) {
+ builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
+ val helpLink = resources.getString(R.string.install_prod_keys_warning_help)
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
+ startActivity(intent)
+ }
+ }
+
+ return builder.show()
+ }
+
+ companion object {
+ const val TAG = "SetupWarningDialogFragment"
+
+ private const val TITLE = "Title"
+ private const val DESCRIPTION = "Description"
+ private const val HELP_LINK = "HelpLink"
+ private const val PAGE = "Page"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int,
+ page: Int
+ ): SetupWarningDialogFragment {
+ val dialog = SetupWarningDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE, titleId)
+ putInt(DESCRIPTION, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ putInt(PAGE, page)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt
new file mode 100644
index 000000000..be5e4c86c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.layout
+
+import android.content.Context
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Recycler
+import org.yuzu.yuzu_emu.R
+
+/**
+ * Cut down version of the solution provided here
+ * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
+ */
+class AutofitGridLayoutManager(
+ context: Context,
+ columnWidth: Int
+) : GridLayoutManager(context, 1) {
+ private var columnWidth = 0
+ private var isColumnWidthChanged = true
+ private var lastWidth = 0
+ private var lastHeight = 0
+
+ init {
+ setColumnWidth(checkedColumnWidth(context, columnWidth))
+ }
+
+ private fun checkedColumnWidth(context: Context, columnWidth: Int): Int {
+ var newColumnWidth = columnWidth
+ if (newColumnWidth <= 0) {
+ newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
+ }
+ return newColumnWidth
+ }
+
+ private fun setColumnWidth(newColumnWidth: Int) {
+ if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
+ columnWidth = newColumnWidth
+ isColumnWidthChanged = true
+ }
+ }
+
+ override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) {
+ val width = width
+ val height = height
+ if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
+ val totalSpace: Int = if (orientation == VERTICAL) {
+ width - paddingRight - paddingLeft
+ } else {
+ height - paddingTop - paddingBottom
+ }
+ val spanCount = 1.coerceAtLeast(totalSpace / columnWidth)
+ setSpanCount(spanCount)
+ isColumnWidthChanged = false
+ }
+ lastWidth = width
+ lastHeight = height
+ super.onLayoutChildren(recycler, state)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
new file mode 100644
index 000000000..2a17653b2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+import java.util.HashSet
+
+@Parcelize
+@Serializable
+class Game(
+ val title: String,
+ val description: String,
+ val regions: String,
+ val path: String,
+ val gameId: String,
+ val company: String
+) : Parcelable {
+ val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
+ val keyLastPlayedTime get() = "${gameId}_LastPlayed"
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is Game)
+ return false
+
+ return title == other.title
+ && description == other.description
+ && regions == other.regions
+ && path == other.path
+ && gameId == other.gameId
+ && company == other.company
+ }
+
+ companion object {
+ val extensions: Set<String> = HashSet(
+ listOf(".xci", ".nsp", ".nca", ".nro")
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
new file mode 100644
index 000000000..7059856f1
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.utils.GameHelper
+import java.util.Locale
+
+class GamesViewModel : ViewModel() {
+ private val _games = MutableLiveData<List<Game>>(emptyList())
+ val games: LiveData<List<Game>> get() = _games
+
+ private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
+ val searchedGames: LiveData<List<Game>> get() = _searchedGames
+
+ private val _isReloading = MutableLiveData(false)
+ val isReloading: LiveData<Boolean> get() = _isReloading
+
+ private val _shouldSwapData = MutableLiveData(false)
+ val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
+
+ private val _shouldScrollToTop = MutableLiveData(false)
+ val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop
+
+ private val _searchFocused = MutableLiveData(false)
+ val searchFocused: LiveData<Boolean> get() = _searchFocused
+
+ init {
+ // Ensure keys are loaded so that ROM metadata can be decrypted.
+ NativeLibrary.reloadKeys()
+
+ // Retrieve list of cached games
+ val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ .getStringSet(GameHelper.KEY_GAMES, emptySet())
+ if (storedGames!!.isNotEmpty()) {
+ val deserializedGames = mutableSetOf<Game>()
+ storedGames.forEach {
+ val game: Game = Json.decodeFromString(it)
+ val gameExists =
+ DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path))
+ ?.exists()
+ if (gameExists == true) {
+ deserializedGames.add(game)
+ }
+ }
+ setGames(deserializedGames.toList())
+ }
+ reloadGames(false)
+ }
+
+ fun setGames(games: List<Game>) {
+ val sortedList = games.sortedWith(
+ compareBy(
+ { it.title.lowercase(Locale.getDefault()) },
+ { it.path }
+ )
+ )
+
+ _games.postValue(sortedList)
+ }
+
+ fun setSearchedGames(games: List<Game>) {
+ _searchedGames.postValue(games)
+ }
+
+ fun setShouldSwapData(shouldSwap: Boolean) {
+ _shouldSwapData.postValue(shouldSwap)
+ }
+
+ fun setShouldScrollToTop(shouldScroll: Boolean) {
+ _shouldScrollToTop.postValue(shouldScroll)
+ }
+
+ fun setSearchFocused(searchFocused: Boolean) {
+ _searchFocused.postValue(searchFocused)
+ }
+
+ fun reloadGames(directoryChanged: Boolean) {
+ if (isReloading.value == true)
+ return
+ _isReloading.postValue(true)
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ NativeLibrary.resetRomMetadata()
+ setGames(GameHelper.getGames())
+ _isReloading.postValue(false)
+
+ if (directoryChanged) {
+ setShouldSwapData(true)
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
new file mode 100644
index 000000000..7049f2fa5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class HomeSetting(
+ val titleId: Int,
+ val descriptionId: Int,
+ val iconId: Int,
+ val onClick: () -> Unit
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
new file mode 100644
index 000000000..263ee7144
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class HomeViewModel : ViewModel() {
+ private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>()
+ val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible
+
+ private val _statusBarShadeVisible = MutableLiveData(true)
+ val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible
+
+ var navigatedToSetup = false
+
+ init {
+ _navigationVisible.value = Pair(false, false)
+ }
+
+ fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
+ if (_navigationVisible.value?.first == visible) {
+ return
+ }
+ _navigationVisible.value = Pair(visible, animated)
+ }
+
+ fun setStatusBarShadeVisibility(visible: Boolean) {
+ if (_statusBarShadeVisible.value == visible) {
+ return
+ }
+ _statusBarShadeVisible.value = visible
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt
new file mode 100644
index 000000000..f24d5cf34
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/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/yuzu/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
new file mode 100644
index 000000000..b4b78e42d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.net.Uri
+import android.provider.DocumentsContract
+
+class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) {
+ val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
new file mode 100644
index 000000000..a0c878e1c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class SetupPage(
+ val iconId: Int,
+ val titleId: Int,
+ val descriptionId: Int,
+ val buttonIconId: Int,
+ val leftAlignedIcon: Boolean,
+ val buttonTextId: Int,
+ val buttonAction: () -> Unit,
+ val hasWarning: Boolean,
+ val warningTitleId: Int = 0,
+ val warningDescriptionId: Int = 0,
+ val warningHelpLinkId: Int = 0,
+ val taskCompleted: () -> Boolean = { true }
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
new file mode 100644
index 000000000..27ea725a5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class TaskViewModel : ViewModel() {
+ private val _result = MutableLiveData<Any>()
+ val result: LiveData<Any> = _result
+
+ private val _isComplete = MutableLiveData<Boolean>()
+ val isComplete: LiveData<Boolean> = _isComplete
+
+ private val _isRunning = MutableLiveData<Boolean>()
+ val isRunning: LiveData<Boolean> = _isRunning
+
+ lateinit var task: () -> Any
+
+ init {
+ clear()
+ }
+
+ fun clear() {
+ _result.value = Any()
+ _isComplete.value = false
+ _isRunning.value = false
+ }
+
+ fun runTask() {
+ if (_isRunning.value == true) {
+ return
+ }
+ _isRunning.value = true
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val res = task()
+ _result.postValue(res)
+ _isComplete.postValue(true)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
new file mode 100644
index 000000000..c9f5797ac
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
@@ -0,0 +1,1064 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.app.Activity
+import android.content.Context
+import android.content.SharedPreferences
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.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.preference.PreferenceManager
+import androidx.window.layout.WindowMetricsCalculator
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonType
+import org.yuzu.yuzu_emu.NativeLibrary.StickType
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Draws the interactive input overlay on top of the
+ * [SurfaceView] that is rendering emulation.
+ */
+class InputOverlay(context: Context, attrs: AttributeSet?) : SurfaceView(context, attrs),
+ OnTouchListener {
+ private val overlayButtons: MutableSet<InputOverlayDrawableButton> = HashSet()
+ private val overlayDpads: MutableSet<InputOverlayDrawableDpad> = HashSet()
+ private val overlayJoysticks: MutableSet<InputOverlayDrawableJoystick> = 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
+
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
+ super.onLayout(changed, left, top, right, bottom)
+
+ windowInsets = rootWindowInsets
+
+ if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) {
+ defaultOverlay()
+ }
+
+ // Load the controls.
+ refreshControls()
+
+ // Set the on touch listener.
+ setOnTouchListener(this)
+
+ // Force draw
+ setWillNotDraw(false)
+
+ // Request focus for the overlay so it has priority on presses.
+ requestFocus()
+ }
+
+ override fun draw(canvas: Canvas) {
+ super.draw(canvas)
+ for (button in overlayButtons) {
+ button.draw(canvas)
+ }
+ for (dpad in overlayDpads) {
+ dpad.draw(canvas)
+ }
+ for (joystick in overlayJoysticks) {
+ joystick.draw(canvas)
+ }
+ }
+
+ override fun onTouch(v: View, event: MotionEvent): Boolean {
+ if (inEditMode) {
+ return onTouchWhileEditing(event)
+ }
+
+ var shouldUpdateView = false
+ val playerIndex =
+ if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device
+
+ for (button in overlayButtons) {
+ if (!button.updateStatus(event)) {
+ continue
+ }
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ button.buttonId,
+ button.status
+ )
+ playHaptics(event)
+ shouldUpdateView = true
+ }
+
+ for (dpad in overlayDpads) {
+ if (!dpad.updateStatus(event, EmulationMenuSettings.dpadSlide)) {
+ continue
+ }
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ dpad.upId,
+ dpad.upStatus
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ dpad.downId,
+ dpad.downStatus
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ dpad.leftId,
+ dpad.leftStatus
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ dpad.rightId,
+ dpad.rightStatus
+ )
+ playHaptics(event)
+ shouldUpdateView = true
+ }
+
+ for (joystick in overlayJoysticks) {
+ if (!joystick.updateStatus(event)) {
+ continue
+ }
+ val axisID = joystick.joystickId
+ NativeLibrary.onGamePadJoystickEvent(
+ playerIndex,
+ axisID,
+ joystick.xAxis,
+ joystick.realYAxis
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerIndex,
+ joystick.buttonId,
+ joystick.buttonStatus
+ )
+ playHaptics(event)
+ shouldUpdateView = true
+ }
+
+ if (shouldUpdateView)
+ invalidate()
+
+ if (!preferences.getBoolean(Settings.PREF_TOUCH_ENABLED, true)) {
+ return true
+ }
+
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionMove = motionEvent == MotionEvent.ACTION_MOVE
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+
+ if (isActionDown && !isTouchInputConsumed(pointerId)) {
+ NativeLibrary.onTouchPressed(pointerId, xPosition.toFloat(), yPosition.toFloat())
+ }
+
+ if (isActionMove) {
+ for (i in 0 until event.pointerCount) {
+ val fingerId = event.getPointerId(i)
+ if (isTouchInputConsumed(fingerId)) {
+ continue
+ }
+ NativeLibrary.onTouchMoved(fingerId, event.getX(i), event.getY(i))
+ }
+ }
+
+ if (isActionUp && !isTouchInputConsumed(pointerId)) {
+ NativeLibrary.onTouchReleased(pointerId)
+ }
+
+ return true
+ }
+
+ private fun playHaptics(event: MotionEvent) {
+ if (EmulationMenuSettings.hapticFeedback) {
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_POINTER_DOWN ->
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_POINTER_UP ->
+ performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE)
+ }
+ }
+ }
+
+ private fun isTouchInputConsumed(track_id: Int): Boolean {
+ for (button in overlayButtons) {
+ if (button.trackId == track_id) {
+ return true
+ }
+ }
+ for (dpad in overlayDpads) {
+ if (dpad.trackId == track_id) {
+ return true
+ }
+ }
+ for (joystick in overlayJoysticks) {
+ if (joystick.trackId == track_id) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun onTouchWhileEditing(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ // TODO: Provide support for portrait layout
+ //val orientation =
+ // if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else ""
+
+ for (button in overlayButtons) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ when (event.action and MotionEvent.ACTION_MASK) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_POINTER_DOWN ->
+ // If no button is being moved now, remember the currently touched button to move.
+ if (buttonBeingConfigured == null &&
+ button.bounds.contains(
+ fingerPositionX,
+ fingerPositionY
+ )
+ ) {
+ buttonBeingConfigured = button
+ buttonBeingConfigured!!.onConfigureTouch(event)
+ }
+
+ MotionEvent.ACTION_MOVE -> if (buttonBeingConfigured != null) {
+ buttonBeingConfigured!!.onConfigureTouch(event)
+ invalidate()
+ return true
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_POINTER_UP -> if (buttonBeingConfigured === button) {
+ // Persist button position by saving new place.
+ saveControlPosition(
+ buttonBeingConfigured!!.buttonId,
+ buttonBeingConfigured!!.bounds.centerX(),
+ buttonBeingConfigured!!.bounds.centerY(),
+ ""
+ )
+ buttonBeingConfigured = null
+ }
+ }
+ }
+
+ for (dpad in overlayDpads) {
+ // Determine the button state to apply based on the MotionEvent action flag.
+ when (event.action and MotionEvent.ACTION_MASK) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_POINTER_DOWN ->
+ // If no button is being moved now, remember the currently touched button to move.
+ if (buttonBeingConfigured == null &&
+ dpad.bounds.contains(fingerPositionX, fingerPositionY)
+ ) {
+ dpadBeingConfigured = dpad
+ dpadBeingConfigured!!.onConfigureTouch(event)
+ }
+
+ MotionEvent.ACTION_MOVE -> if (dpadBeingConfigured != null) {
+ dpadBeingConfigured!!.onConfigureTouch(event)
+ invalidate()
+ return true
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_POINTER_UP -> if (dpadBeingConfigured === dpad) {
+ // Persist button position by saving new place.
+ saveControlPosition(
+ dpadBeingConfigured!!.upId,
+ dpadBeingConfigured!!.bounds.centerX(),
+ dpadBeingConfigured!!.bounds.centerY(),
+ ""
+ )
+ dpadBeingConfigured = null
+ }
+ }
+ }
+
+ for (joystick in overlayJoysticks) {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN,
+ MotionEvent.ACTION_POINTER_DOWN -> if (joystickBeingConfigured == null &&
+ joystick.bounds.contains(
+ fingerPositionX,
+ fingerPositionY
+ )
+ ) {
+ joystickBeingConfigured = joystick
+ joystickBeingConfigured!!.onConfigureTouch(event)
+ }
+
+ MotionEvent.ACTION_MOVE -> if (joystickBeingConfigured != null) {
+ joystickBeingConfigured!!.onConfigureTouch(event)
+ invalidate()
+ }
+
+ MotionEvent.ACTION_UP,
+ MotionEvent.ACTION_POINTER_UP -> if (joystickBeingConfigured != null) {
+ saveControlPosition(
+ joystickBeingConfigured!!.buttonId,
+ joystickBeingConfigured!!.bounds.centerX(),
+ joystickBeingConfigured!!.bounds.centerY(),
+ ""
+ )
+ joystickBeingConfigured = null
+ }
+ }
+ }
+
+ return true
+ }
+
+ private fun addOverlayControls(orientation: String) {
+ val windowSize = getSafeScreenSize(context)
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_0, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_a,
+ R.drawable.facebutton_a_depressed,
+ ButtonType.BUTTON_A,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_1, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_b,
+ R.drawable.facebutton_b_depressed,
+ ButtonType.BUTTON_B,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_2, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_x,
+ R.drawable.facebutton_x_depressed,
+ ButtonType.BUTTON_X,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_3, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_y,
+ R.drawable.facebutton_y_depressed,
+ ButtonType.BUTTON_Y,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_4, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.l_shoulder,
+ R.drawable.l_shoulder_depressed,
+ ButtonType.TRIGGER_L,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_5, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.r_shoulder,
+ R.drawable.r_shoulder_depressed,
+ ButtonType.TRIGGER_R,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_6, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.zl_trigger,
+ R.drawable.zl_trigger_depressed,
+ ButtonType.TRIGGER_ZL,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_7, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.zr_trigger,
+ R.drawable.zr_trigger_depressed,
+ ButtonType.TRIGGER_ZR,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_8, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_plus,
+ R.drawable.facebutton_plus_depressed,
+ ButtonType.BUTTON_PLUS,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_9, true)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_minus,
+ R.drawable.facebutton_minus_depressed,
+ ButtonType.BUTTON_MINUS,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_10, true)) {
+ overlayDpads.add(
+ initializeOverlayDpad(
+ context,
+ windowSize,
+ R.drawable.dpad_standard,
+ R.drawable.dpad_standard_cardinal_depressed,
+ R.drawable.dpad_standard_diagonal_depressed,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_11, true)) {
+ overlayJoysticks.add(
+ initializeOverlayJoystick(
+ context,
+ windowSize,
+ R.drawable.joystick_range,
+ R.drawable.joystick,
+ R.drawable.joystick_depressed,
+ StickType.STICK_L,
+ ButtonType.STICK_L,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_12, true)) {
+ overlayJoysticks.add(
+ initializeOverlayJoystick(
+ context,
+ windowSize,
+ R.drawable.joystick_range,
+ R.drawable.joystick,
+ R.drawable.joystick_depressed,
+ StickType.STICK_R,
+ ButtonType.STICK_R,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_13, false)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_home,
+ R.drawable.facebutton_home_depressed,
+ ButtonType.BUTTON_HOME,
+ orientation
+ )
+ )
+ }
+ if (preferences.getBoolean(Settings.PREF_BUTTON_TOGGLE_14, false)) {
+ overlayButtons.add(
+ initializeOverlayButton(
+ context,
+ windowSize,
+ R.drawable.facebutton_screenshot,
+ R.drawable.facebutton_screenshot_depressed,
+ ButtonType.BUTTON_CAPTURE,
+ orientation
+ )
+ )
+ }
+ }
+
+ fun refreshControls() {
+ // Remove all the overlay buttons from the HashSet.
+ overlayButtons.clear()
+ overlayDpads.clear()
+ overlayJoysticks.clear()
+ val orientation =
+ if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) "-Portrait" else ""
+
+ // Add all the enabled overlay items back to the HashSet.
+ if (EmulationMenuSettings.showOverlay) {
+ addOverlayControls(orientation)
+ }
+ invalidate()
+ }
+
+ private fun saveControlPosition(sharedPrefsId: Int, x: Int, y: Int, orientation: String) {
+ val windowSize = getSafeScreenSize(context)
+ val min = windowSize.first
+ val max = windowSize.second
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit()
+ .putFloat("$sharedPrefsId$orientation-X", (x - min.x).toFloat() / max.x)
+ .putFloat("$sharedPrefsId$orientation-Y", (y - min.y).toFloat() / max.y)
+ .apply()
+ }
+
+ fun setIsInEditMode(editMode: Boolean) {
+ inEditMode = editMode
+ }
+
+ private fun defaultOverlay() {
+ if (!preferences.getBoolean(Settings.PREF_OVERLAY_INIT, false)) {
+ defaultOverlayLandscape()
+ }
+
+ resetButtonPlacement()
+ preferences.edit()
+ .putBoolean(Settings.PREF_OVERLAY_INIT, true)
+ .apply()
+ }
+
+ fun resetButtonPlacement() {
+ defaultOverlayLandscape()
+ refreshControls()
+ }
+
+ private fun defaultOverlayLandscape() {
+ // Each value represents the position of the button in relation to the screen size without insets.
+ preferences.edit()
+ .putFloat(
+ ButtonType.BUTTON_A.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_A_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_A.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_A_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_B.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_B_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_B.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_B_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_X.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_X_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_X.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_X_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_Y.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_Y_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_Y.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_Y_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_ZL.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_ZL.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_ZL_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_ZR.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_ZR.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_ZR_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.DPAD_UP.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.DPAD_UP.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_DPAD_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_L.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_L_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_L.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_L_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_R.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_R_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.TRIGGER_R.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_TRIGGER_R_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_PLUS.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_PLUS.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_PLUS_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_MINUS.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_MINUS.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_MINUS_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_HOME.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_HOME_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_HOME.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_HOME_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_CAPTURE.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_X)
+ .toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.BUTTON_CAPTURE.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_BUTTON_CAPTURE_Y)
+ .toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.STICK_R.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_STICK_R_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.STICK_R.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_STICK_R_Y).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.STICK_L.toString() + "-X",
+ resources.getInteger(R.integer.SWITCH_STICK_L_X).toFloat() / 1000
+ )
+ .putFloat(
+ ButtonType.STICK_L.toString() + "-Y",
+ resources.getInteger(R.integer.SWITCH_STICK_L_Y).toFloat() / 1000
+ )
+ .apply()
+ }
+
+ override fun isInEditMode(): Boolean {
+ return inEditMode
+ }
+
+ companion object {
+ private val preferences: SharedPreferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ /**
+ * 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): Pair<Point, Point> {
+ // Get screen size
+ val windowMetrics =
+ WindowMetricsCalculator.getOrCreate()
+ .computeCurrentWindowMetrics(context as Activity)
+ var maxY = windowMetrics.bounds.height().toFloat()
+ var maxX = windowMetrics.bounds.width().toFloat()
+ var minY = 0
+ var minX = 0
+
+ // If we have API access, calculate the safe area to draw the overlay
+ var cutoutLeft = 0
+ var cutoutBottom = 0
+ val insets = context.windowManager.currentWindowMetrics.windowInsets.displayCutout
+ if (insets != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (insets.boundingRectTop.bottom != 0 && insets.boundingRectTop.bottom > maxY / 2)
+ insets.boundingRectTop.bottom.toFloat() else maxY
+ if (insets.boundingRectRight.left != 0 && insets.boundingRectRight.left > maxX / 2)
+ insets.boundingRectRight.left.toFloat() else maxX
+
+ minX = insets.boundingRectLeft.right - insets.boundingRectLeft.left
+ minY = insets.boundingRectBottom.top - insets.boundingRectBottom.bottom
+
+ cutoutLeft = insets.boundingRectRight.right - insets.boundingRectRight.left
+ cutoutBottom = insets.boundingRectTop.top - insets.boundingRectTop.bottom
+ }
+
+ // This makes sure that if we have an inset on one side of the screen, we mirror it on
+ // the other side. Since removing space from one of the max values messes with the scale,
+ // we also have to account for it using our min values.
+ if (maxX.toInt() != windowMetrics.bounds.width()) minX += cutoutLeft
+ if (maxY.toInt() != windowMetrics.bounds.height()) minY += cutoutBottom
+ if (minX > 0 && maxX.toInt() == windowMetrics.bounds.width()) {
+ maxX -= (minX * 2)
+ } else if (minX > 0) {
+ maxX -= minX
+ }
+ if (minY > 0 && maxY.toInt() == windowMetrics.bounds.height()) {
+ maxY -= (minY * 2)
+ } else if (minY > 0) {
+ maxY -= minY
+ }
+
+ 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
+ * within a SharedPreferences instance so that those values can be retrieved here.
+ *
+ *
+ * This has a few benefits over the conventional way of storing the values
+ * (ie. within the yuzu ini file).
+ *
+ * * No native calls
+ * * Keeps Android-only values inside the Android environment
+ *
+ *
+ *
+ * Technically no modifications should need to be performed on the returned
+ * InputOverlayDrawableButton. Simply add it to the HashSet of overlay items and wait
+ * for Android to call the onDraw method.
+ *
+ * @param context The current [Context].
+ * @param 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.
+ * @return An [InputOverlayDrawableButton] with the correct drawing bounds set.
+ */
+ private fun initializeOverlayButton(
+ context: Context,
+ windowSize: Pair<Point, Point>,
+ defaultResId: Int,
+ pressedResId: Int,
+ buttonId: Int,
+ orientation: String
+ ): InputOverlayDrawableButton {
+ // Resources handle for fetching the initial Drawable resource.
+ val res = context.resources
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableButton.
+ val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // Decide scale based on button ID and user preference
+ var scale: Float = when (buttonId) {
+ ButtonType.BUTTON_HOME,
+ ButtonType.BUTTON_CAPTURE,
+ ButtonType.BUTTON_PLUS,
+ ButtonType.BUTTON_MINUS -> 0.07f
+
+ ButtonType.TRIGGER_L,
+ ButtonType.TRIGGER_R,
+ ButtonType.TRIGGER_ZL,
+ ButtonType.TRIGGER_ZR -> 0.26f
+
+ else -> 0.11f
+ }
+ scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat()
+ scale /= 100f
+
+ // Initialize the InputOverlayDrawableButton.
+ val defaultStateBitmap = getBitmap(context, defaultResId, scale)
+ val pressedStateBitmap = getBitmap(context, pressedResId, scale)
+ val overlayDrawable =
+ InputOverlayDrawableButton(res, defaultStateBitmap, pressedStateBitmap, buttonId)
+
+ // 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 xKey = "$buttonId$orientation-X"
+ val yKey = "$buttonId$orientation-Y"
+ val drawableXPercent = sPrefs.getFloat(xKey, 0f)
+ val drawableYPercent = sPrefs.getFloat(yKey, 0f)
+ val drawableX = (drawableXPercent * max.x + min.x).toInt()
+ val drawableY = (drawableYPercent * 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)
+ )
+ val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100)
+ overlayDrawable.setOpacity(savedOpacity * 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.
+ * @return the initialized [InputOverlayDrawableDpad]
+ */
+ private fun initializeOverlayDpad(
+ context: Context,
+ windowSize: Pair<Point, Point>,
+ defaultResId: Int,
+ pressedOneDirectionResId: Int,
+ pressedTwoDirectionsResId: Int,
+ orientation: String
+ ): InputOverlayDrawableDpad {
+ // Resources handle for fetching the initial Drawable resource.
+ val res = context.resources
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableDpad.
+ val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // Decide scale based on button ID and user preference
+ var scale = 0.25f
+ scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat()
+ scale /= 100f
+
+ // Initialize the InputOverlayDrawableDpad.
+ val defaultStateBitmap =
+ getBitmap(context, defaultResId, scale)
+ val pressedOneDirectionStateBitmap = getBitmap(context, pressedOneDirectionResId, scale)
+ val pressedTwoDirectionsStateBitmap =
+ getBitmap(context, pressedTwoDirectionsResId, scale)
+
+ val overlayDrawable = InputOverlayDrawableDpad(
+ res,
+ defaultStateBitmap,
+ pressedOneDirectionStateBitmap,
+ pressedTwoDirectionsStateBitmap,
+ ButtonType.DPAD_UP,
+ ButtonType.DPAD_DOWN,
+ ButtonType.DPAD_LEFT,
+ ButtonType.DPAD_RIGHT
+ )
+
+ // 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 drawableXPercent = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-X", 0f)
+ val drawableYPercent = sPrefs.getFloat("${ButtonType.DPAD_UP}$orientation-Y", 0f)
+ val drawableX = (drawableXPercent * max.x + min.x).toInt()
+ val drawableY = (drawableYPercent * 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))
+ val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100)
+ overlayDrawable.setOpacity(savedOpacity * 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 button Identifier for which joystick button this is.
+ * @return the initialized [InputOverlayDrawableJoystick].
+ */
+ private fun initializeOverlayJoystick(
+ context: Context,
+ windowSize: Pair<Point, Point>,
+ resOuter: Int,
+ defaultResInner: Int,
+ pressedResInner: Int,
+ joystick: Int,
+ button: Int,
+ orientation: String
+ ): InputOverlayDrawableJoystick {
+ // Resources handle for fetching the initial Drawable resource.
+ val res = context.resources
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
+ val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // Decide scale based on user preference
+ var scale = 0.3f
+ scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat()
+ scale /= 100f
+
+ // Initialize the InputOverlayDrawableJoystick.
+ val bitmapOuter = getBitmap(context, resOuter, scale)
+ val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f)
+ val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f)
+
+ // 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 drawableXPercent = sPrefs.getFloat("$button$orientation-X", 0f)
+ val drawableYPercent = sPrefs.getFloat("$button$orientation-Y", 0f)
+ val drawableX = (drawableXPercent * max.x + min.x).toInt()
+ val drawableY = (drawableYPercent * 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
+ )
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY)
+ val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100)
+ overlayDrawable.setOpacity(savedOpacity * 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
new file mode 100644
index 000000000..4a93e0b14
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param defaultStateBitmap [Bitmap] to use with the default state Drawable.
+ * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable.
+ * @param buttonId Identifier for this type of button.
+ */
+class InputOverlayDrawableButton(
+ res: Resources,
+ defaultStateBitmap: Bitmap,
+ pressedStateBitmap: Bitmap,
+ val buttonId: Int
+) {
+ // The ID value what motion event is tracking
+ var trackId: Int
+
+ // The drawable position on the screen
+ private var buttonPositionX = 0
+ private var buttonPositionY = 0
+
+ val width: Int
+ val height: Int
+
+ private val defaultStateBitmap: BitmapDrawable
+ private val pressedStateBitmap: BitmapDrawable
+ private var pressedState = false
+
+ private var previousTouchX = 0
+ private var previousTouchY = 0
+ var controlPositionX = 0
+ var controlPositionY = 0
+
+ init {
+ this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
+ this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap)
+ trackId = -1
+ width = this.defaultStateBitmap.intrinsicWidth
+ height = this.defaultStateBitmap.intrinsicHeight
+ }
+
+ /**
+ * Updates button status based on the motion event.
+ *
+ * @return true if value was changed
+ */
+ fun updateStatus(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ pressedState = true
+ trackId = pointerId
+ return true
+ }
+
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ pressedState = false
+ trackId = -1
+ return true
+ }
+
+ return false
+ }
+
+ fun setPosition(x: Int, y: Int) {
+ buttonPositionX = x
+ buttonPositionY = y
+ }
+
+ fun draw(canvas: Canvas?) {
+ currentStateBitmapDrawable.draw(canvas!!)
+ }
+
+ private val currentStateBitmapDrawable: BitmapDrawable
+ get() = if (pressedState) pressedStateBitmap else defaultStateBitmap
+
+ fun onConfigureTouch(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ controlPositionX = fingerPositionX - (width / 2)
+ controlPositionY = fingerPositionY - (height / 2)
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ controlPositionX += fingerPositionX - previousTouchX
+ controlPositionY += fingerPositionY - previousTouchY
+ setBounds(
+ controlPositionX,
+ controlPositionY,
+ width + controlPositionX,
+ height + controlPositionY
+ )
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+ }
+ return true
+ }
+
+ fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
+ defaultStateBitmap.setBounds(left, top, right, bottom)
+ pressedStateBitmap.setBounds(left, top, right, bottom)
+ }
+
+ 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
new file mode 100644
index 000000000..43d664d21
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt
@@ -0,0 +1,274 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param defaultStateBitmap [Bitmap] of the default state.
+ * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction.
+ * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction.
+ * @param buttonUp Identifier for the up button.
+ * @param buttonDown Identifier for the down button.
+ * @param buttonLeft Identifier for the left button.
+ * @param buttonRight Identifier for the right button.
+ */
+class InputOverlayDrawableDpad(
+ res: Resources,
+ defaultStateBitmap: Bitmap,
+ pressedOneDirectionStateBitmap: Bitmap,
+ pressedTwoDirectionsStateBitmap: Bitmap,
+ buttonUp: Int,
+ buttonDown: Int,
+ buttonLeft: Int,
+ buttonRight: Int
+) {
+ /**
+ * Gets one of the InputOverlayDrawableDpad's button IDs.
+ *
+ * @return the requested InputOverlayDrawableDpad's button ID.
+ */
+ // The ID identifying what type of button this Drawable represents.
+ val upId: Int
+ val downId: Int
+ val leftId: Int
+ val rightId: Int
+ var trackId: Int
+
+ val width: Int
+ val height: Int
+
+ private val defaultStateBitmap: BitmapDrawable
+ private val pressedOneDirectionStateBitmap: BitmapDrawable
+ private val pressedTwoDirectionsStateBitmap: BitmapDrawable
+
+ private var previousTouchX = 0
+ private var previousTouchY = 0
+ private var controlPositionX = 0
+ private var controlPositionY = 0
+
+ private var upButtonState = false
+ private var downButtonState = false
+ private var leftButtonState = false
+ private var rightButtonState = false
+
+ init {
+ this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
+ this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap)
+ this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap)
+ width = this.defaultStateBitmap.intrinsicWidth
+ height = this.defaultStateBitmap.intrinsicHeight
+ upId = buttonUp
+ downId = buttonDown
+ leftId = buttonLeft
+ rightId = buttonRight
+ trackId = -1
+ }
+
+ fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ trackId = pointerId
+ }
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ trackId = -1
+ upButtonState = false
+ downButtonState = false
+ leftButtonState = false
+ rightButtonState = false
+ return true
+ }
+ if (trackId == -1) {
+ return false
+ }
+ if (!dpad_slide && !isActionDown) {
+ return false
+ }
+ for (i in 0 until event.pointerCount) {
+ if (trackId != event.getPointerId(i)) {
+ continue
+ }
+
+ var touchX = event.getX(i)
+ var touchY = event.getY(i)
+ var maxY = bounds.bottom.toFloat()
+ var maxX = bounds.right.toFloat()
+ touchX -= bounds.centerX().toFloat()
+ maxX -= bounds.centerX().toFloat()
+ touchY -= bounds.centerY().toFloat()
+ maxY -= bounds.centerY().toFloat()
+ val axisX = touchX / maxX
+ val axisY = touchY / maxY
+ val oldUpState = upButtonState
+ val oldDownState = downButtonState
+ val oldLeftState = leftButtonState
+ val oldRightState = rightButtonState
+
+ upButtonState = axisY < -VIRT_AXIS_DEADZONE
+ downButtonState = axisY > VIRT_AXIS_DEADZONE
+ leftButtonState = axisX < -VIRT_AXIS_DEADZONE
+ rightButtonState = axisX > VIRT_AXIS_DEADZONE
+ return oldUpState != upButtonState || oldDownState != downButtonState || oldLeftState != leftButtonState || oldRightState != rightButtonState
+ }
+ return false
+ }
+
+ fun draw(canvas: Canvas) {
+ val px = controlPositionX + width / 2
+ val py = controlPositionY + height / 2
+
+ // Pressed up
+ if (upButtonState && !leftButtonState && !rightButtonState) {
+ pressedOneDirectionStateBitmap.draw(canvas)
+ return
+ }
+
+ // Pressed down
+ if (downButtonState && !leftButtonState && !rightButtonState) {
+ canvas.save()
+ canvas.rotate(180f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed left
+ if (leftButtonState && !upButtonState && !downButtonState) {
+ canvas.save()
+ canvas.rotate(270f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed right
+ if (rightButtonState && !upButtonState && !downButtonState) {
+ canvas.save()
+ canvas.rotate(90f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed up left
+ if (upButtonState && leftButtonState && !rightButtonState) {
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ return
+ }
+
+ // Pressed up right
+ if (upButtonState && !leftButtonState && rightButtonState) {
+ canvas.save()
+ canvas.rotate(90f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed down right
+ if (downButtonState && !leftButtonState && rightButtonState) {
+ canvas.save()
+ canvas.rotate(180f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed down left
+ if (downButtonState && leftButtonState && !rightButtonState) {
+ canvas.save()
+ canvas.rotate(270f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Not pressed
+ defaultStateBitmap.draw(canvas)
+ }
+
+ val upStatus: Int
+ get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val downStatus: Int
+ get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val leftStatus: Int
+ get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val rightStatus: Int
+ get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+
+ fun onConfigureTouch(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ controlPositionX += fingerPositionX - previousTouchX
+ controlPositionY += fingerPositionY - previousTouchY
+ setBounds(
+ controlPositionX,
+ controlPositionY,
+ width + controlPositionX,
+ height + controlPositionY
+ )
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+ }
+ return true
+ }
+
+ fun setPosition(x: Int, y: Int) {
+ controlPositionX = x
+ controlPositionY = y
+ }
+
+ fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
+ defaultStateBitmap.setBounds(left, top, right, bottom)
+ pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom)
+ pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom)
+ }
+
+ 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
new file mode 100644
index 000000000..f1d32192a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt
@@ -0,0 +1,282 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick.
+ * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick.
+ * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick.
+ * @param rectOuter [Rect] which represents the outer joystick bounds.
+ * @param rectInner [Rect] which represents the inner joystick bounds.
+ * @param joystickId The ID value what type of joystick this Drawable represents.
+ * @param buttonId The ID value what type of button this Drawable represents.
+ */
+class InputOverlayDrawableJoystick(
+ res: Resources,
+ bitmapOuter: Bitmap,
+ bitmapInnerDefault: Bitmap,
+ bitmapInnerPressed: Bitmap,
+ rectOuter: Rect,
+ rectInner: Rect,
+ val joystickId: Int,
+ val buttonId: Int
+) {
+ // The ID value what motion event is tracking
+ var trackId = -1
+
+ var xAxis = 0f
+ private var yAxis = 0f
+
+ val width: Int
+ val height: Int
+
+ private var 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() =
+ NativeLibrary.ButtonState.RELEASED
+ var bounds: Rect
+ get() = outerBitmap.bounds
+ set(bounds) {
+ outerBitmap.bounds = bounds
+ }
+
+ // Nintendo joysticks have y axis inverted
+ val realYAxis: Float
+ get() = -yAxis
+
+ private val currentStateBitmapDrawable: BitmapDrawable
+ get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap
+
+ init {
+ outerBitmap = BitmapDrawable(res, bitmapOuter)
+ defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault)
+ pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed)
+ boundsBoxBitmap = BitmapDrawable(res, bitmapOuter)
+ width = bitmapOuter.width
+ height = bitmapOuter.height
+ bounds = rectOuter
+ defaultStateInnerBitmap.bounds = rectInner
+ pressedStateInnerBitmap.bounds = rectInner
+ virtBounds = bounds
+ origBounds = outerBitmap.copyBounds()
+ boundsBoxBitmap.alpha = 0
+ boundsBoxBitmap.bounds = virtBounds
+ setInnerBounds()
+ }
+
+ fun draw(canvas: Canvas?) {
+ outerBitmap.draw(canvas!!)
+ currentStateBitmapDrawable.draw(canvas)
+ boundsBoxBitmap.draw(canvas)
+ }
+
+ fun updateStatus(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ pressedState = true
+ outerBitmap.alpha = 0
+ boundsBoxBitmap.alpha = opacity
+ if (EmulationMenuSettings.joystickRelCenter) {
+ virtBounds.offset(
+ xPosition - virtBounds.centerX(),
+ yPosition - virtBounds.centerY()
+ )
+ }
+ boundsBoxBitmap.bounds = virtBounds
+ trackId = pointerId
+ }
+
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ pressedState = false
+ xAxis = 0.0f
+ yAxis = 0.0f
+ outerBitmap.alpha = 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/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
new file mode 100644
index 000000000..97eef40d2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.transition.MaterialFadeThrough
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.GameAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding
+import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+
+class GamesFragment : Fragment() {
+ private var _binding: FragmentGamesBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGamesBinding.inflate(inflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = false)
+
+ binding.gridGames.apply {
+ layoutManager = AutofitGridLayoutManager(
+ requireContext(),
+ requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.swipeRefresh.apply {
+ // Add swipe down to refresh gesture
+ setOnRefreshListener {
+ gamesViewModel.reloadGames(false)
+ }
+
+ // Set theme color to the refresh animation's background
+ setProgressBackgroundColorSchemeColor(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorPrimary
+ )
+ )
+ setColorSchemeColors(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorOnPrimary
+ )
+ )
+
+ // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
+ post {
+ if (_binding == null) {
+ return@post
+ }
+ binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
+ }
+ }
+
+ gamesViewModel.apply {
+ // Watch for when we get updates to any of our games lists
+ isReloading.observe(viewLifecycleOwner) { isReloading ->
+ binding.swipeRefresh.isRefreshing = isReloading
+ }
+ games.observe(viewLifecycleOwner) {
+ (binding.gridGames.adapter as GameAdapter).submitList(it)
+ if (it.isEmpty()) {
+ binding.noticeText.visibility = View.VISIBLE
+ } else {
+ binding.noticeText.visibility = View.GONE
+ }
+ }
+ shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
+ if (shouldSwapData) {
+ (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!)
+ gamesViewModel.setShouldSwapData(false)
+ }
+ }
+
+ // Check if the user reselected the games menu item and then scroll to top of the list
+ shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll ->
+ if (shouldScroll) {
+ scrollToTop()
+ gamesViewModel.setShouldScrollToTop(false)
+ }
+ }
+ }
+
+ setInsets()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun scrollToTop() {
+ if (_binding != null) {
+ binding.gridGames.smoothScrollToPosition(0)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ binding.gridGames.updatePadding(
+ top = barInsets.top + extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+
+ binding.swipeRefresh.setProgressViewEndTarget(
+ false,
+ barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
+ )
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+ val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
+ mlpSwipe.rightMargin = rightInsets
+ } else {
+ mlpSwipe.leftMargin = leftInsets
+ mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
+ }
+ binding.swipeRefresh.layoutParams = mlpSwipe
+
+ binding.noticeText.updatePadding(bottom = spacingNavigation)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
new file mode 100644
index 000000000..124f62f08
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -0,0 +1,470 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui.main
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+import android.view.WindowManager
+import android.view.animation.PathInterpolator
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import androidx.preference.PreferenceManager
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.navigation.NavigationBarView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
+import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.*
+import java.io.File
+import java.io.FilenameFilter
+import java.io.IOException
+
+class MainActivity : AppCompatActivity(), ThemeProvider {
+ private lateinit var binding: ActivityMainBinding
+
+ private val homeViewModel: HomeViewModel by viewModels()
+ private val gamesViewModel: GamesViewModel by viewModels()
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override var themeId: Int = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
+
+ settingsViewModel.settings.loadSettings()
+
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+
+ window.statusBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+ window.navigationBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+
+ binding.statusBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
+ binding.navigationBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ }
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+ setUpNavigation(navHostFragment.navController)
+ (binding.navigationView as NavigationBarView).setOnItemReselectedListener {
+ when (it.itemId) {
+ R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
+ R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
+ R.id.homeSettingsFragment -> SettingsActivity.launch(
+ this,
+ SettingsFile.FILE_NAME_CONFIG,
+ ""
+ )
+ }
+ }
+
+ // Prevents navigation from being drawn for a short time on recreation if set to hidden
+ if (!homeViewModel.navigationVisible.value?.first!!) {
+ binding.navigationView.visibility = View.INVISIBLE
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+
+ homeViewModel.navigationVisible.observe(this) {
+ showNavigation(it.first, it.second)
+ }
+ homeViewModel.statusBarShadeVisible.observe(this) { visible ->
+ showStatusBarShade(visible)
+ }
+
+ // Dismiss previous notifications (should not happen unless a crash occurred)
+ EmulationActivity.stopForegroundService(this)
+
+ setInsets()
+ }
+
+ fun finishSetup(navController: NavController) {
+ navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ showNavigation(visible = true, animated = true)
+ }
+
+ private fun setUpNavigation(navController: NavController) {
+ val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
+
+ if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
+ navController.navigate(R.id.firstTimeSetupFragment)
+ homeViewModel.navigatedToSetup = true
+ } else {
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ }
+ }
+
+ private fun showNavigation(visible: Boolean, animated: Boolean) {
+ if (!animated) {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ } else {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ return
+ }
+
+ val smallLayout = resources.getBoolean(R.bool.small_layout)
+ binding.navigationView.animate().apply {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ duration = 300
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+
+ if (smallLayout) {
+ binding.navigationView.translationY =
+ binding.navigationView.height.toFloat() * 2
+ translationY(0f)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * -2
+ translationX(0f)
+ } else {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * 2
+ translationX(0f)
+ }
+ }
+ } else {
+ duration = 300
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+
+ if (smallLayout) {
+ translationY(binding.navigationView.height.toFloat() * 2)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ translationX(binding.navigationView.width.toFloat() * -2)
+ } else {
+ translationX(binding.navigationView.width.toFloat() * 2)
+ }
+ }
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ private fun showStatusBarShade(visible: Boolean) {
+ binding.statusBarShade.animate().apply {
+ if (visible) {
+ binding.statusBarShade.visibility = View.VISIBLE
+ binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
+ duration = 300
+ translationY(0f)
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+ } else {
+ duration = 300
+ translationY(binding.navigationView.height.toFloat() * -2)
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ override fun onResume() {
+ ThemeHelper.setCorrectTheme(this)
+ super.onResume()
+ }
+
+ override fun onDestroy() {
+ EmulationActivity.stopForegroundService(this)
+ super.onDestroy()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
+ mlpStatusShade.height = insets.top
+ binding.statusBarShade.layoutParams = mlpStatusShade
+
+ // The only situation where we care to have a nav bar shade is when it's at the bottom
+ // of the screen where scrolling list elements can go behind it.
+ val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
+ mlpNavShade.height = insets.bottom
+ binding.navigationBarShade.layoutParams = mlpNavShade
+
+ windowInsets
+ }
+
+ override fun setTheme(resId: Int) {
+ super.setTheme(resId)
+ themeId = resId
+ }
+
+ val getGamesDirectory =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
+ .putString(GameHelper.KEY_GAME_PATH, result.toString())
+ .apply()
+
+ Toast.makeText(
+ applicationContext,
+ R.string.games_dir_selected,
+ Toast.LENGTH_LONG
+ ).show()
+
+ gamesViewModel.reloadGames(true)
+ }
+
+ val getProdKey =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ if (!FileUtil.hasExtension(result.toString(), "keys")) {
+ MessageDialogFragment.newInstance(
+ R.string.reading_keys_failure,
+ R.string.install_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(
+ applicationContext,
+ result,
+ dstPath,
+ "prod.keys"
+ )
+ ) {
+ if (NativeLibrary.reloadKeys()) {
+ Toast.makeText(
+ applicationContext,
+ R.string.install_keys_success,
+ Toast.LENGTH_SHORT
+ ).show()
+ gamesViewModel.reloadGames(true)
+ } else {
+ MessageDialogFragment.newInstance(
+ R.string.invalid_keys_error,
+ R.string.install_keys_failure_description,
+ R.string.dumping_keys_quickstart_link
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+ }
+
+ val getFirmware =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ val inputZip = contentResolver.openInputStream(result)
+ if (inputZip == null) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ return@registerForActivityResult
+ }
+
+ val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
+
+ val firmwarePath =
+ File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ val cacheFirmwareDir = File("${cacheDir.path}/registered/")
+
+ val task: () -> Any = {
+ var messageToShow: Any
+ try {
+ FileUtil.unzip(inputZip, cacheFirmwareDir)
+ val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
+ val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
+ messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
+ MessageDialogFragment.newInstance(
+ R.string.firmware_installed_failure,
+ R.string.firmware_installed_failure_description
+ )
+ } else {
+ firmwarePath.deleteRecursively()
+ cacheFirmwareDir.copyRecursively(firmwarePath, true)
+ getString(R.string.save_file_imported_success)
+ }
+ } catch (e: Exception) {
+ messageToShow = getString(R.string.fatal_error)
+ } finally {
+ cacheFirmwareDir.deleteRecursively()
+ }
+ messageToShow
+ }
+
+ IndeterminateProgressDialogFragment.newInstance(
+ this,
+ R.string.firmware_installing,
+ task
+ ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
+
+ val getAmiiboKey =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ if (!FileUtil.hasExtension(result.toString(), "bin")) {
+ MessageDialogFragment.newInstance(
+ R.string.reading_keys_failure,
+ R.string.install_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(
+ applicationContext,
+ result,
+ dstPath,
+ "key_retail.bin"
+ )
+ ) {
+ if (NativeLibrary.reloadKeys()) {
+ Toast.makeText(
+ applicationContext,
+ R.string.install_keys_success,
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ MessageDialogFragment.newInstance(
+ R.string.invalid_keys_error,
+ R.string.install_keys_failure_description,
+ R.string.dumping_keys_quickstart_link
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+ }
+
+ val getDriver =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ val takeFlags =
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ contentResolver.takePersistableUriPermission(
+ result,
+ takeFlags
+ )
+
+ val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
+ progressBinding.progressBar.isIndeterminate = true
+ val installationDialog = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.installing_driver)
+ .setView(progressBinding.root)
+ .show()
+
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ // Ignore file exceptions when a user selects an invalid zip
+ try {
+ GpuDriverHelper.installCustomDriver(applicationContext, result)
+ } catch (_: IOException) {
+ }
+
+ withContext(Dispatchers.Main) {
+ installationDialog.dismiss()
+
+ val driverName = GpuDriverHelper.customDriverName
+ if (driverName != null) {
+ Toast.makeText(
+ applicationContext,
+ getString(
+ R.string.select_gpu_driver_install_success,
+ driverName
+ ),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ applicationContext,
+ R.string.select_gpu_driver_error,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
new file mode 100644
index 000000000..511a6e4fa
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui.main
+
+interface ThemeProvider {
+ /**
+ * Provides theme ID by overriding an activity's 'setTheme' method and returning that result
+ */
+ var themeId: Int
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
new file mode 100644
index 000000000..9cfda74ee
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+class BiMap<K, V> {
+ private val forward: MutableMap<K, V> = HashMap()
+ private val backward: MutableMap<V, K> = HashMap()
+
+ @Synchronized
+ fun add(key: K, value: V) {
+ forward[key] = value
+ backward[value] = key
+ }
+
+ @Synchronized
+ fun getForward(key: K): V? {
+ return forward[key]
+ }
+
+ @Synchronized
+ fun getBackward(key: V): K? {
+ return backward[key]
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt
new file mode 100644
index 000000000..791cea904
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+
+/**
+ * Some controllers have incorrect mappings. This class has special-case fixes for them.
+ */
+class ControllerMappingHelper {
+ /**
+ * Some controllers report extra button presses that can be ignored.
+ */
+ fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
+ return if (isDualShock4(inputDevice)) {
+ // The two analog triggers generate analog motion events as well as a keycode.
+ // We always prefer to use the analog values, so throw away the button press
+ keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
+ } else false
+ }
+
+ /**
+ * Scale an axis to be zero-centered with a proper range.
+ */
+ fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
+ if (isDualShock4(inputDevice)) {
+ // Android doesn't have correct mappings for this controller's triggers. It reports them
+ // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
+ // Scale them to properly zero-centered with a range of [0.0, 1.0].
+ if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
+ return (value + 1) / 2.0f
+ }
+ } else if (isXboxOneWireless(inputDevice)) {
+ // Same as the DualShock 4, the mappings are missing.
+ if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
+ return (value + 1) / 2.0f
+ }
+ if (axis == MotionEvent.AXIS_GENERIC_1) {
+ // This axis is stuck at ~.5. Ignore it.
+ return 0.0f
+ }
+ } else if (isMogaPro2Hid(inputDevice)) {
+ // This controller has a broken axis that reports a constant value. Ignore it.
+ if (axis == MotionEvent.AXIS_GENERIC_1) {
+ return 0.0f
+ }
+ }
+ return value
+ }
+
+ // Sony DualShock 4 controller
+ private fun isDualShock4(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
+ }
+
+ // Microsoft Xbox One controller
+ private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
+ }
+
+ // Moga Pro 2 HID
+ private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
new file mode 100644
index 000000000..36c479e6c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Context
+import org.yuzu.yuzu_emu.NativeLibrary
+import java.io.IOException
+
+object DirectoryInitialization {
+ private var userPath: String? = null
+
+ var areDirectoriesReady: Boolean = false
+
+ fun start(context: Context) {
+ if (!areDirectoriesReady) {
+ initializeInternalStorage(context)
+ NativeLibrary.initializeEmulation()
+ areDirectoriesReady = true
+ }
+ }
+
+ val userDirectory: String?
+ get() {
+ check(areDirectoriesReady) { "Directory initialization is not ready!" }
+ return userPath
+ }
+
+ private fun initializeInternalStorage(context: Context) {
+ try {
+ userPath = context.getExternalFilesDir(null)!!.canonicalPath
+ NativeLibrary.setAppDirectory(userPath!!)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
new file mode 100644
index 000000000..cc8ea6b9d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.MinimalDocumentFile
+import java.io.File
+import java.util.*
+
+class DocumentsTree {
+ private var root: DocumentsNode? = null
+
+ fun setRoot(rootUri: Uri?) {
+ root = null
+ root = DocumentsNode()
+ root!!.uri = rootUri
+ root!!.isDirectory = true
+ }
+
+ fun openContentUri(filepath: String, openMode: String?): Int {
+ val node = resolvePath(filepath) ?: return -1
+ return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode)
+ }
+
+ fun getFileSize(filepath: String): Long {
+ val node = resolvePath(filepath)
+ return if (node == null || node.isDirectory) {
+ 0
+ } else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString())
+ }
+
+ fun exists(filepath: String): Boolean {
+ return resolvePath(filepath) != null
+ }
+
+ private fun resolvePath(filepath: String): DocumentsNode? {
+ val tokens = StringTokenizer(filepath, File.separator, false)
+ var iterator = root
+ while (tokens.hasMoreTokens()) {
+ val token = tokens.nextToken()
+ if (token.isEmpty()) continue
+ iterator = find(iterator, token)
+ if (iterator == null) return null
+ }
+ return iterator
+ }
+
+ private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? {
+ if (parent!!.isDirectory && !parent.loaded) {
+ structTree(parent)
+ }
+ return parent.children[filename]
+ }
+
+ /**
+ * Construct current level directory tree
+ * @param parent parent node of this level
+ */
+ private fun structTree(parent: DocumentsNode) {
+ val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!)
+ for (document in documents) {
+ val node = DocumentsNode(document)
+ node.parent = parent
+ parent.children[node.name] = node
+ }
+ parent.loaded = true
+ }
+
+ private class DocumentsNode {
+ var parent: DocumentsNode? = null
+ val children: MutableMap<String?, DocumentsNode> = HashMap()
+ var name: String? = null
+ var uri: Uri? = null
+ var loaded = false
+ var isDirectory = false
+
+ constructor()
+ constructor(document: MinimalDocumentFile) {
+ name = document.filename
+ uri = document.uri
+ isDirectory = document.isDirectory
+ loaded = !isDirectory
+ }
+
+ private constructor(document: DocumentFile, isCreateDir: Boolean) {
+ name = document.name
+ uri = document.uri
+ isDirectory = isCreateDir
+ loaded = true
+ }
+
+ private fun rename(name: String) {
+ if (parent == null) {
+ return
+ }
+ parent!!.children.remove(this.name)
+ this.name = name
+ parent!!.children[name] = this
+ }
+ }
+
+ companion object {
+ fun isNativePath(path: String): Boolean {
+ return if (path.isNotEmpty()) {
+ path[0] == '/'
+ } else false
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt
new file mode 100644
index 000000000..e1e7a59d7
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+
+object EmulationMenuSettings {
+ private val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // These must match what is defined in src/core/settings.h
+ const val LayoutOption_Default = 0
+ const val LayoutOption_SingleScreen = 1
+ const val LayoutOption_LargeScreen = 2
+ const val LayoutOption_SideScreen = 3
+ const val LayoutOption_MobilePortrait = 4
+ const val LayoutOption_MobileLandscape = 5
+
+ var joystickRelCenter: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value)
+ .apply()
+ }
+ var dpadSlide: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value)
+ .apply()
+ }
+ var hapticFeedback: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value)
+ .apply()
+ }
+
+ var landscapeScreenLayout: Int
+ get() = preferences.getInt(
+ Settings.PREF_MENU_SETTINGS_LANDSCAPE,
+ LayoutOption_MobileLandscape
+ )
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value)
+ .apply()
+ }
+ var showFps: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value)
+ .apply()
+ }
+ var showOverlay: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value)
+ .apply()
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
new file mode 100644
index 000000000..593dad8d3
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -0,0 +1,330 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.documentfile.provider.DocumentFile
+import org.yuzu.yuzu_emu.model.MinimalDocumentFile
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.URLDecoder
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+object FileUtil {
+ const val PATH_TREE = "tree"
+ const val DECODE_METHOD = "UTF-8"
+ const val APPLICATION_OCTET_STREAM = "application/octet-stream"
+ const val TEXT_PLAIN = "text/plain"
+
+ /**
+ * Create a file from directory with filename.
+ * @param context Application context
+ * @param directory parent path for file.
+ * @param filename file display name.
+ * @return boolean
+ */
+ fun createFile(context: Context?, directory: String?, filename: String): DocumentFile? {
+ var decodedFilename = filename
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
+ decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD)
+ var mimeType = APPLICATION_OCTET_STREAM
+ if (decodedFilename.endsWith(".txt")) {
+ mimeType = TEXT_PLAIN
+ }
+ val exists = parent.findFile(decodedFilename)
+ return exists ?: parent.createFile(mimeType, decodedFilename)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ }
+ return null
+ }
+
+ /**
+ * Create a directory from directory with filename.
+ * @param context Application context
+ * @param directory parent path for directory.
+ * @param directoryName directory display name.
+ * @return boolean
+ */
+ fun createDir(context: Context?, directory: String?, directoryName: String?): DocumentFile? {
+ var decodedDirectoryName = directoryName
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
+ decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD)
+ val isExist = parent.findFile(decodedDirectoryName)
+ return isExist ?: parent.createDirectory(decodedDirectoryName)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ }
+ return null
+ }
+
+ /**
+ * Open content uri and return file descriptor to JNI.
+ * @param context Application context
+ * @param path Native content uri path
+ * @param openMode will be one of "r", "r", "rw", "wa", "rwa"
+ * @return file descriptor
+ */
+ @JvmStatic
+ fun openContentUri(context: Context, path: String, openMode: String?): Int {
+ try {
+ val uri = Uri.parse(path)
+ val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!)
+ if (parcelFileDescriptor == null) {
+ Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
+ return -1
+ }
+ val fileDescriptor = parcelFileDescriptor.detachFd()
+ parcelFileDescriptor.close()
+ return fileDescriptor
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
+ }
+ return -1
+ }
+
+ /**
+ * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
+ * This function will be faster than DoucmentFile.listFiles
+ * @param context Application context
+ * @param uri Directory uri.
+ * @return CheapDocument lists.
+ */
+ fun listFiles(context: Context, uri: Uri): Array<MinimalDocumentFile> {
+ 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<MinimalDocumentFile> = ArrayList()
+ try {
+ val docId: String = if (isRootTreeUri(uri)) {
+ DocumentsContract.getTreeDocumentId(uri)
+ } else {
+ DocumentsContract.getDocumentId(uri)
+ }
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
+ c = resolver.query(childrenUri, columns, null, null, null)
+ while (c!!.moveToNext()) {
+ val documentId = c.getString(0)
+ val documentName = c.getString(1)
+ val documentMimeType = c.getString(2)
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ val document = MinimalDocumentFile(documentName, documentMimeType, documentUri)
+ results.add(document)
+ }
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list file error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return results.toTypedArray()
+ }
+
+ /**
+ * Check whether given path exists.
+ * @param path Native content uri path
+ * @return bool
+ */
+ fun exists(context: Context, path: String?): Boolean {
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ c = context.contentResolver.query(mUri, columns, null, null, null)
+ return c!!.count > 0
+ } catch (e: Exception) {
+ Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return false
+ }
+
+ /**
+ * Check whether given path is a directory
+ * @param path content uri path
+ * @return bool
+ */
+ fun isDirectory(context: Context, path: String): Boolean {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_MIME_TYPE
+ )
+ var isDirectory = false
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ val mimeType = c.getString(0)
+ isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list files, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return isDirectory
+ }
+
+ /**
+ * Get file display name from given path
+ * @param path content uri path
+ * @return String display name
+ */
+ fun getFilename(context: Context, path: String): String {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME
+ )
+ var filename = ""
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ filename = c.getString(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return filename
+ }
+
+ fun getFilesName(context: Context, path: String): Array<String> {
+ val uri = Uri.parse(path)
+ val files: MutableList<String> = ArrayList()
+ for (file in listFiles(context, uri)) {
+ files.add(file.filename)
+ }
+ return files.toTypedArray()
+ }
+
+ /**
+ * Get file size from given path.
+ * @param path content uri path
+ * @return long file size
+ */
+ @JvmStatic
+ fun getFileSize(context: Context, path: String): Long {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_SIZE
+ )
+ var size: Long = 0
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ size = c.getLong(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return size
+ }
+
+ fun copyUriToInternalStorage(
+ context: Context,
+ sourceUri: Uri?,
+ destinationParentPath: String,
+ destinationFilename: String
+ ): Boolean {
+ var input: InputStream? = null
+ var output: FileOutputStream? = null
+ try {
+ input = context.contentResolver.openInputStream(sourceUri!!)
+ output = FileOutputStream("$destinationParentPath/$destinationFilename")
+ val buffer = ByteArray(1024)
+ var len: Int
+ while (input!!.read(buffer).also { len = it } != -1) {
+ output.write(buffer, 0, len)
+ }
+ output.flush()
+ return true
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot copy file, error: " + e.message)
+ } finally {
+ if (input != null) {
+ try {
+ input.close()
+ } catch (e: IOException) {
+ Log.error("[FileUtil]: Cannot close input file, error: " + e.message)
+ }
+ }
+ if (output != null) {
+ try {
+ output.close()
+ } catch (e: IOException) {
+ Log.error("[FileUtil]: Cannot close output file, error: " + e.message)
+ }
+ }
+ }
+ return false
+ }
+
+ /**
+ * Extracts the given zip file into the given directory.
+ * @exception IOException if the file was being created outside of the target directory
+ */
+ @Throws(SecurityException::class)
+ fun unzip(zipStream: InputStream, destDir: File): Boolean {
+ ZipInputStream(BufferedInputStream(zipStream)).use { zis ->
+ var entry: ZipEntry? = zis.nextEntry
+ while (entry != null) {
+ val entryName = entry.name
+ val entryFile = File(destDir, entryName)
+ if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
+ throw SecurityException("Entry is outside of the target dir: " + entryFile.name)
+ }
+ if (entry.isDirectory) {
+ entryFile.mkdirs()
+ } else {
+ entryFile.parentFile?.mkdirs()
+ entryFile.createNewFile()
+ entryFile.outputStream().use { fos -> zis.copyTo(fos) }
+ }
+ entry = zis.nextEntry
+ }
+ }
+
+ return true
+ }
+
+ fun isRootTreeUri(uri: Uri): Boolean {
+ val paths = uri.pathSegments
+ return paths.size == 2 && PATH_TREE == paths[0]
+ }
+
+ fun closeQuietly(closeable: AutoCloseable?) {
+ if (closeable != null) {
+ try {
+ closeable.close()
+ } catch (rethrown: RuntimeException) {
+ throw rethrown
+ } catch (ignored: Exception) {
+ }
+ }
+ }
+
+ fun hasExtension(path: String, extension: String): Boolean {
+ return path.substring(path.lastIndexOf(".") + 1).contains(extension)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
new file mode 100644
index 000000000..dc9b7c744
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+
+/**
+ * A service that shows a permanent notification in the background to avoid the app getting
+ * cleared from memory by the system.
+ */
+class ForegroundService : Service() {
+ companion object {
+ const val EMULATION_RUNNING_NOTIFICATION = 0x1000
+
+ const val ACTION_STOP = "stop"
+ }
+
+ private fun showRunningNotification() {
+ // Intent is used to resume emulation if the notification is clicked
+ val contentIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, EmulationActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val builder =
+ NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id))
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.emulation_notification_running))
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setVibrate(null)
+ .setSound(null)
+ .setContentIntent(contentIntent)
+ startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build())
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return null
+ }
+
+ override fun onCreate() {
+ showRunningNotification()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent == null) {
+ return START_NOT_STICKY;
+ }
+ if (intent.action == ACTION_STOP) {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelfResult(startId)
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
new file mode 100644
index 000000000..ba6b5783e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.SharedPreferences
+import android.net.Uri
+import androidx.preference.PreferenceManager
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.Game
+import java.util.*
+
+object GameHelper {
+ const val KEY_GAME_PATH = "game_path"
+ const val KEY_GAMES = "Games"
+
+ private lateinit var preferences: SharedPreferences
+
+ fun getGames(): List<Game> {
+ val games = mutableListOf<Game>()
+ val context = YuzuApplication.appContext
+ val gamesDir =
+ PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
+ val gamesUri = Uri.parse(gamesDir)
+ preferences = PreferenceManager.getDefaultSharedPreferences(context)
+
+ // Ensure keys are loaded so that ROM metadata can be decrypted.
+ NativeLibrary.reloadKeys()
+
+ val children = FileUtil.listFiles(context, gamesUri)
+ for (file in children) {
+ if (!file.isDirectory) {
+ val filename = file.uri.toString()
+ val extensionStart = filename.lastIndexOf('.')
+ if (extensionStart > 0) {
+ val fileExtension = filename.substring(extensionStart)
+
+ // Check that the file has an extension we care about before trying to read out of it.
+ if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
+ games.add(getGame(filename))
+ }
+ }
+ }
+ }
+
+ // Cache list of games found on disk
+ val serializedGames = mutableSetOf<String>()
+ games.forEach {
+ serializedGames.add(Json.encodeToString(it))
+ }
+ preferences.edit()
+ .remove(KEY_GAMES)
+ .putStringSet(KEY_GAMES, serializedGames)
+ .apply()
+
+ return games.toList()
+ }
+
+ private fun getGame(filePath: String): Game {
+ var name = NativeLibrary.getTitle(filePath)
+
+ // If the game's title field is empty, use the filename.
+ if (name.isEmpty()) {
+ name = filePath.substring(filePath.lastIndexOf("/") + 1)
+ }
+ var gameId = NativeLibrary.getGameId(filePath)
+
+ // If the game's ID field is empty, use the filename without extension.
+ if (gameId.isEmpty()) {
+ gameId = filePath.substring(
+ filePath.lastIndexOf("/") + 1,
+ filePath.lastIndexOf(".")
+ )
+ }
+
+ val newGame = Game(
+ name,
+ NativeLibrary.getDescription(filePath).replace("\n", " "),
+ NativeLibrary.getRegions(filePath),
+ filePath,
+ gameId,
+ NativeLibrary.getCompany(filePath)
+ )
+
+ val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
+ if (addedTime == 0L) {
+ preferences.edit()
+ .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
+ .apply()
+ }
+
+ return newGame
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
new file mode 100644
index 000000000..528011d7f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.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.Context
+import android.net.Uri
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.zip.ZipInputStream
+
+object GpuDriverHelper {
+ private const val META_JSON_FILENAME = "meta.json"
+ private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip"
+ private var fileRedirectionPath: String? = null
+ private var driverInstallationPath: String? = null
+ private var hookLibPath: String? = null
+
+ @Throws(IOException::class)
+ private fun unzip(zipFilePath: String, destDir: String) {
+ val dir = File(destDir)
+
+ // Create output directory if it doesn't exist
+ if (!dir.exists()) dir.mkdirs()
+
+ // Unpack the files.
+ val inputStream = FileInputStream(zipFilePath)
+ val zis = ZipInputStream(BufferedInputStream(inputStream))
+ val buffer = ByteArray(1024)
+ var ze = zis.nextEntry
+ while (ze != null) {
+ val newFile = File(destDir, ze.name)
+ val canonicalPath = newFile.canonicalPath
+ if (!canonicalPath.startsWith(destDir + ze.name)) {
+ throw SecurityException("Zip file attempted path traversal! " + ze.name)
+ }
+
+ newFile.parentFile!!.mkdirs()
+ val fos = FileOutputStream(newFile)
+ var len: Int
+ while (zis.read(buffer).also { len = it } > 0) {
+ fos.write(buffer, 0, len)
+ }
+ fos.close()
+ zis.closeEntry()
+ ze = zis.nextEntry
+ }
+ zis.closeEntry()
+ }
+
+ fun initializeDriverParameters(context: Context) {
+ try {
+ // Initialize the file redirection directory.
+ fileRedirectionPath =
+ context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/"
+
+ // Initialize the driver installation directory.
+ driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/"
+ } catch (e: IOException) {
+ throw RuntimeException(e)
+ }
+
+ // Initialize directories.
+ initializeDirectories()
+
+ // Initialize hook libraries directory.
+ hookLibPath = context.applicationInfo.nativeLibraryDir + "/"
+
+ // Initialize GPU driver.
+ NativeLibrary.initializeGpuDriver(
+ hookLibPath,
+ driverInstallationPath,
+ customDriverLibraryName,
+ fileRedirectionPath
+ )
+ }
+
+ fun installDefaultDriver(context: Context) {
+ // Removing the installed driver will result in the backend using the default system driver.
+ val driverInstallationDir = File(driverInstallationPath!!)
+ deleteRecursive(driverInstallationDir)
+ initializeDriverParameters(context)
+ }
+
+ fun installCustomDriver(context: Context, driverPathUri: Uri?) {
+ // Revert to system default in the event the specified driver is bad.
+ installDefaultDriver(context)
+
+ // Ensure we have directories.
+ initializeDirectories()
+
+ // Copy the zip file URI into our private storage.
+ copyUriToInternalStorage(
+ context,
+ driverPathUri,
+ driverInstallationPath!!,
+ DRIVER_INTERNAL_FILENAME
+ )
+
+ // Unzip the driver.
+ try {
+ unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!)
+ } catch (e: SecurityException) {
+ return
+ }
+
+ // Initialize the driver parameters.
+ initializeDriverParameters(context)
+ }
+
+ // Parse the custom driver metadata to retrieve the name.
+ val customDriverName: String?
+ get() {
+ val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
+ return metadata.name
+ }
+
+ // Parse the custom driver metadata to retrieve the library name.
+ private val customDriverLibraryName: String?
+ get() {
+ // Parse the custom driver metadata to retrieve the library name.
+ val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
+ return metadata.libraryName
+ }
+
+ private fun initializeDirectories() {
+ // Ensure the file redirection directory exists.
+ val fileRedirectionDir = File(fileRedirectionPath!!)
+ if (!fileRedirectionDir.exists()) {
+ fileRedirectionDir.mkdirs()
+ }
+ // Ensure the driver installation directory exists.
+ val driverInstallationDir = File(driverInstallationPath!!)
+ if (!driverInstallationDir.exists()) {
+ driverInstallationDir.mkdirs()
+ }
+ }
+
+ private fun deleteRecursive(fileOrDirectory: File) {
+ if (fileOrDirectory.isDirectory) {
+ for (child in fileOrDirectory.listFiles()!!) {
+ deleteRecursive(child)
+ }
+ }
+ fileOrDirectory.delete()
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
new file mode 100644
index 000000000..70bdb4097
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Paths
+
+class GpuDriverMetadata(metadataFilePath: String) {
+ var name: String? = null
+ var description: String? = null
+ var author: String? = null
+ var vendor: String? = null
+ var driverVersion: String? = null
+ var minApi = 0
+ var libraryName: String? = null
+
+ init {
+ try {
+ val json = JSONObject(getStringFromFile(metadataFilePath))
+ name = json.getString("name")
+ description = json.getString("description")
+ author = json.getString("author")
+ vendor = json.getString("vendor")
+ driverVersion = json.getString("driverVersion")
+ minApi = json.getInt("minApi")
+ libraryName = json.getString("libraryName")
+ } catch (e: JSONException) {
+ // JSON is malformed, ignore and treat as unsupported metadata.
+ } catch (e: IOException) {
+ // File is inaccessible, ignore and treat as unsupported metadata.
+ }
+ }
+
+ companion object {
+ @Throws(IOException::class)
+ private fun getStringFromFile(filePath: String): String {
+ val path = Paths.get(filePath)
+ val bytes = Files.readAllBytes(path)
+ return String(bytes, StandardCharsets.UTF_8)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
new file mode 100644
index 000000000..24e999b29
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
@@ -0,0 +1,360 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary
+import kotlin.math.sqrt
+
+class InputHandler {
+ fun initialize() {
+ // Connect first controller
+ NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device))
+ }
+
+ fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val button: Int = when (event.device.vendorId) {
+ 0x045E -> getInputXboxButtonKey(event.keyCode)
+ 0x054C -> getInputDS5ButtonKey(event.keyCode)
+ 0x057E -> getInputJoyconButtonKey(event.keyCode)
+ 0x1532 -> getInputRazerButtonKey(event.keyCode)
+ else -> getInputGenericButtonKey(event.keyCode)
+ }
+
+ val action = when (event.action) {
+ KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED
+ KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED
+ else -> return false
+ }
+
+ // Ignore invalid buttons
+ if (button < 0) {
+ return false
+ }
+
+ return NativeLibrary.onGamePadButtonEvent(
+ getPlayerNumber(event.device.controllerNumber),
+ button,
+ action
+ )
+ }
+
+ fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ val device = event.device
+ // Check every axis input available on the controller
+ for (range in device.motionRanges) {
+ val axis = range.axis
+ when (device.vendorId) {
+ 0x045E -> setGenericAxisInput(event, axis)
+ 0x054C -> setGenericAxisInput(event, axis)
+ 0x057E -> setJoyconAxisInput(event, axis)
+ 0x1532 -> setRazerAxisInput(event, axis)
+ else -> setGenericAxisInput(event, axis)
+ }
+ }
+
+ return true
+ }
+
+ private fun getPlayerNumber(index: Int): Int {
+ // TODO: Joycons are handled as different controllers. Find a way to merge them.
+ return when (index) {
+ 2 -> NativeLibrary.Player2Device
+ 3 -> NativeLibrary.Player3Device
+ 4 -> NativeLibrary.Player4Device
+ 5 -> NativeLibrary.Player5Device
+ 6 -> NativeLibrary.Player6Device
+ 7 -> NativeLibrary.Player7Device
+ 8 -> NativeLibrary.Player8Device
+ else -> if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device
+ }
+ }
+
+ private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) {
+ // Calculate vector size
+ val r2 = xAxis * xAxis + yAxis * yAxis
+ var r = sqrt(r2.toDouble()).toFloat()
+
+ // Adjust range of joystick
+ val deadzone = 0.15f
+ var x = xAxis
+ var y = yAxis
+
+ if (r > deadzone) {
+ val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone)
+ x *= deadzoneFactor
+ y *= deadzoneFactor
+ r *= deadzoneFactor
+ } else {
+ x = 0.0f
+ y = 0.0f
+ }
+
+ // Normalize joystick
+ if (r > 1.0f) {
+ x /= r
+ y /= r
+ }
+
+ NativeLibrary.onGamePadJoystickEvent(
+ playerNumber,
+ index,
+ x,
+ -y
+ )
+ }
+
+ private fun getAxisToButton(axis: Float): Int {
+ return if (axis > 0.5f) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED
+ }
+
+ private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) {
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_UP,
+ getAxisToButton(-yAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_DOWN,
+ getAxisToButton(yAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_LEFT,
+ getAxisToButton(-xAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_RIGHT,
+ getAxisToButton(xAxis)
+ )
+ }
+
+ private fun getInputDS5ButtonKey(key: Int): Int {
+ // The missing ds5 buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputJoyconButtonKey(key: Int): Int {
+ // Joycon support is half dead. A lot of buttons can't be mapped
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
+ KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
+ KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
+ KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
+ KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputXboxButtonKey(key: Int): Int {
+ // The missing xbox buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputRazerButtonKey(key: Int): Int {
+ // The missing xbox buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputGenericButtonKey(key: Int): Int {
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
+ KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
+ KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
+ KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
+ KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun setGenericAxisInput(event: MotionEvent, axis: Int) {
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_RX),
+ event.getAxisValue(MotionEvent.AXIS_RY)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_LTRIGGER ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER))
+ )
+ MotionEvent.AXIS_BRAKE ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
+ )
+ MotionEvent.AXIS_RTRIGGER ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER))
+ )
+ MotionEvent.AXIS_GAS ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
+ )
+ MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
+ setAxisDpadState(
+ playerNumber,
+ event.getAxisValue(MotionEvent.AXIS_HAT_X),
+ event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ )
+ }
+ }
+
+
+ private fun setJoyconAxisInput(event: MotionEvent, axis: Int) {
+ // Joycon support is half dead. Right joystick doesn't work
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_RX),
+ event.getAxisValue(MotionEvent.AXIS_RY)
+ )
+ }
+ }
+
+ private fun setRazerAxisInput(event: MotionEvent, axis: Int) {
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_BRAKE ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
+ )
+ MotionEvent.AXIS_GAS ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
+ )
+ MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
+ setAxisDpadState(
+ playerNumber,
+ event.getAxisValue(MotionEvent.AXIS_HAT_X),
+ event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ )
+ }
+ }
+
+
+} \ No newline at end of file
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
new file mode 100644
index 000000000..19c53c481
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.graphics.Rect
+
+object InsetsHelper {
+ const val THREE_BUTTON_NAVIGATION = 0
+ const val TWO_BUTTON_NAVIGATION = 1
+ const val GESTURE_NAVIGATION = 2
+
+ @SuppressLint("DiscouragedApi")
+ fun getSystemGestureType(context: Context): Int {
+ val resources = context.resources
+ val resourceId =
+ resources.getIdentifier("config_navBarInteractionMode", "integer", "android")
+ return if (resourceId != 0) {
+ resources.getInteger(resourceId)
+ } else 0
+ }
+
+ fun getBottomPaddingRequired(activity: Activity): Int {
+ val visibleFrame = Rect()
+ activity.window.decorView.getWindowVisibleDisplayFrame(visibleFrame)
+ return visibleFrame.bottom - visibleFrame.top - activity.resources.displayMetrics.heightPixels
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
new file mode 100644
index 000000000..a193e82a4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.util.Log
+import org.yuzu.yuzu_emu.BuildConfig
+
+/**
+ * Contains methods that call through to [android.util.Log], but
+ * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log
+ * levels in release builds.
+ */
+object Log {
+ private const val TAG = "Yuzu Frontend"
+
+ fun verbose(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.v(TAG, message)
+ }
+ }
+
+ fun debug(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, message)
+ }
+ }
+
+ fun info(message: String) {
+ Log.i(TAG, message)
+ }
+
+ fun warning(message: String) {
+ Log.w(TAG, message)
+ }
+
+ fun error(message: String) {
+ Log.e(TAG, message)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
new file mode 100644
index 000000000..344dd8a0a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.IntentFilter
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.NfcA
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import org.yuzu.yuzu_emu.NativeLibrary
+import java.io.IOException
+
+class NfcReader(private val activity: Activity) {
+ private var nfcAdapter: NfcAdapter? = null
+ private var pendingIntent: PendingIntent? = null
+
+ fun initialize() {
+ nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return
+
+ pendingIntent = PendingIntent.getActivity(
+ activity,
+ 0, Intent(activity, activity.javaClass),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ else PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
+ tagDetected.addCategory(Intent.CATEGORY_DEFAULT)
+ }
+
+ fun startScanning() {
+ nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null)
+ }
+
+ fun stopScanning() {
+ nfcAdapter?.disableForegroundDispatch(activity)
+ }
+
+ fun onNewIntent(intent: Intent) {
+ val action = intent.action
+ if (NfcAdapter.ACTION_TAG_DISCOVERED != action
+ && NfcAdapter.ACTION_TECH_DISCOVERED != action
+ && NfcAdapter.ACTION_NDEF_DISCOVERED != action
+ ) {
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val tag =
+ intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return
+ readTagData(tag)
+ return
+ }
+
+ val tag =
+ intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return
+ readTagData(tag)
+ }
+
+ private fun readTagData(tag: Tag) {
+ if (!tag.techList.contains("android.nfc.tech.NfcA")) {
+ return
+ }
+
+ val amiibo = NfcA.get(tag) ?: return
+ amiibo.connect()
+
+ val tagData = ntag215ReadAll(amiibo) ?: return
+ NativeLibrary.onReadNfcTag(tagData)
+
+ nfcAdapter?.ignore(
+ tag,
+ 1000,
+ { NativeLibrary.onRemoveNfcTag() },
+ Handler(Looper.getMainLooper())
+ )
+ }
+
+ private fun ntag215ReadAll(amiibo: NfcA): ByteArray? {
+ val bufferSize = amiibo.maxTransceiveLength;
+ val tagSize = 0x21C
+ val pageSize = 4
+ val lastPage = tagSize / pageSize - 1
+ val tagData = ByteArray(tagSize)
+
+ // We need to read the ntag in steps otherwise we overflow the buffer
+ for (i in 0..tagSize step bufferSize - 1) {
+ val dataStart = i / pageSize
+ var dataEnd = (i + bufferSize) / pageSize
+
+ if (dataEnd > lastPage) {
+ dataEnd = lastPage
+ }
+
+ try {
+ val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1)
+ System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize)
+ } catch (e: IOException) {
+ return null;
+ }
+ }
+ return tagData
+ }
+
+ private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x30.toByte(),
+ (page and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x3A.toByte(),
+ (start and 0xFF).toByte(),
+ (end and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215PWrite(
+ amiibo: NfcA,
+ page: Int,
+ data1: Int,
+ data2: Int,
+ data3: Int,
+ data4: Int
+ ): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0xA2.toByte(),
+ (page and 0xFF).toByte(),
+ (data1 and 0xFF).toByte(),
+ (data2 and 0xFF).toByte(),
+ (data3 and 0xFF).toByte(),
+ (data4 and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215PwdAuth(
+ amiibo: NfcA,
+ data1: Int,
+ data2: Int,
+ data3: Int,
+ data4: Int
+ ): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x1B.toByte(),
+ (data1 and 0xFF).toByte(),
+ (data2 and 0xFF).toByte(),
+ (data3 and 0xFF).toByte(),
+ (data4 and 0xFF).toByte()
+ )
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt
new file mode 100644
index 000000000..87ee7f2e6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+
+object SerializableHelper {
+ inline fun <reified T : Serializable> 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 <reified T : Serializable> 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 <reified T : Parcelable> 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 <reified T : Parcelable> Intent.parcelable(key: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ getParcelableExtra(key, T::class.java)
+ else
+ getParcelableExtra(key) as? T
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt
new file mode 100644
index 000000000..e55767c0f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.Activity
+import android.content.res.Configuration
+import android.graphics.Color
+import androidx.annotation.ColorInt
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.ui.main.ThemeProvider
+import kotlin.math.roundToInt
+
+object ThemeHelper {
+ const val SYSTEM_BAR_ALPHA = 0.9f
+
+ private const val DEFAULT = 0
+ private const val MATERIAL_YOU = 1
+
+ fun setTheme(activity: AppCompatActivity) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ setThemeMode(activity)
+ when (preferences.getInt(Settings.PREF_THEME, 0)) {
+ DEFAULT -> activity.setTheme(R.style.Theme_Yuzu_Main)
+ MATERIAL_YOU -> activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou)
+ }
+
+ // Using a specific night mode check because this could apply incorrectly when using the
+ // light app mode, dark system mode, and black backgrounds. Launching the settings activity
+ // will then show light mode colors/navigation bars but with black backgrounds.
+ if (preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ && isNightMode(activity)
+ ) {
+ activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark)
+ }
+ }
+
+ @ColorInt
+ fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
+ return Color.argb(
+ (alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color),
+ Color.green(color), Color.blue(color)
+ )
+ }
+
+ fun setCorrectTheme(activity: AppCompatActivity) {
+ val currentTheme = (activity as ThemeProvider).themeId
+ setTheme(activity)
+ if (currentTheme != (activity as ThemeProvider).themeId) {
+ activity.recreate()
+ }
+ }
+
+ fun setThemeMode(activity: AppCompatActivity) {
+ val themeMode = PreferenceManager.getDefaultSharedPreferences(activity.applicationContext)
+ .getInt(Settings.PREF_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ activity.delegate.localNightMode = themeMode
+ val windowController = WindowCompat.getInsetsController(
+ activity.window,
+ activity.window.decorView
+ )
+ when (themeMode) {
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) {
+ false -> setLightModeSystemBars(windowController)
+ true -> setDarkModeSystemBars(windowController)
+ }
+ AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController)
+ AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController)
+ }
+ }
+
+ private fun isNightMode(activity: AppCompatActivity): Boolean {
+ return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
+ Configuration.UI_MODE_NIGHT_NO -> false
+ Configuration.UI_MODE_NIGHT_YES -> true
+ else -> false
+ }
+ }
+
+ private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = true
+ windowController.isAppearanceLightNavigationBars = true
+ }
+
+ private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = false
+ windowController.isAppearanceLightNavigationBars = false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt
new file mode 100644
index 000000000..c8ef8c1fd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt
@@ -0,0 +1,46 @@
+// 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
+import kotlin.math.roundToInt
+
+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
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ val width = MeasureSpec.getSize(widthMeasureSpec)
+ val height = MeasureSpec.getSize(heightMeasureSpec)
+ if (aspectRatio != 0f) {
+ val newWidth: Int
+ val newHeight: Int
+ if (height * aspectRatio < width) {
+ newWidth = (height * aspectRatio).roundToInt()
+ newHeight = height
+ } else {
+ newWidth = width
+ newHeight = (width / aspectRatio).roundToInt()
+ }
+ setMeasuredDimension(newWidth, newHeight)
+ } else {
+ setMeasuredDimension(width, height)
+ }
+ }
+}
diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
new file mode 100644
index 000000000..041781577
--- /dev/null
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+add_library(yuzu-android SHARED
+ android_common/android_common.cpp
+ android_common/android_common.h
+ applets/software_keyboard.cpp
+ applets/software_keyboard.h
+ config.cpp
+ config.h
+ default_ini.h
+ emu_window/emu_window.cpp
+ emu_window/emu_window.h
+ id_cache.cpp
+ id_cache.h
+ native.cpp
+ native.h
+)
+
+set_property(TARGET yuzu-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR})
+
+target_link_libraries(yuzu-android PRIVATE audio_core common core input_common)
+target_link_libraries(yuzu-android PRIVATE android camera2ndk EGL glad inih jnigraphics log)
+if (ARCHITECTURE_arm64)
+ target_link_libraries(yuzu-android PRIVATE adrenotools)
+endif()
+
+set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} yuzu-android)
diff --git a/src/android/app/src/main/jni/android_common/android_common.cpp b/src/android/app/src/main/jni/android_common/android_common.cpp
new file mode 100644
index 000000000..52d8ecfeb
--- /dev/null
+++ b/src/android/app/src/main/jni/android_common/android_common.cpp
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "jni/android_common/android_common.h"
+
+#include <string>
+#include <string_view>
+
+#include <jni.h>
+
+#include "common/string_util.h"
+
+std::string GetJString(JNIEnv* env, jstring jstr) {
+ if (!jstr) {
+ return {};
+ }
+
+ const jchar* jchars = env->GetStringChars(jstr, nullptr);
+ const jsize length = env->GetStringLength(jstr);
+ const std::u16string_view string_view(reinterpret_cast<const char16_t*>(jchars), length);
+ const std::string converted_string = Common::UTF16ToUTF8(string_view);
+ env->ReleaseStringChars(jstr, jchars);
+
+ return converted_string;
+}
+
+jstring ToJString(JNIEnv* env, std::string_view str) {
+ const std::u16string converted_string = Common::UTF8ToUTF16(str);
+ return env->NewString(reinterpret_cast<const jchar*>(converted_string.data()),
+ static_cast<jint>(converted_string.size()));
+}
+
+jstring ToJString(JNIEnv* env, std::u16string_view str) {
+ return ToJString(env, Common::UTF16ToUTF8(str));
+}
diff --git a/src/android/app/src/main/jni/android_common/android_common.h b/src/android/app/src/main/jni/android_common/android_common.h
new file mode 100644
index 000000000..ccb0c06f7
--- /dev/null
+++ b/src/android/app/src/main/jni/android_common/android_common.h
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <string>
+
+#include <jni.h>
+
+std::string GetJString(JNIEnv* env, jstring jstr);
+jstring ToJString(JNIEnv* env, std::string_view str);
+jstring ToJString(JNIEnv* env, std::u16string_view str);
diff --git a/src/android/app/src/main/jni/applets/software_keyboard.cpp b/src/android/app/src/main/jni/applets/software_keyboard.cpp
new file mode 100644
index 000000000..74e040478
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/software_keyboard.cpp
@@ -0,0 +1,277 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <map>
+#include <thread>
+
+#include <jni.h>
+
+#include "common/logging/log.h"
+#include "common/string_util.h"
+#include "core/core.h"
+#include "jni/android_common/android_common.h"
+#include "jni/applets/software_keyboard.h"
+#include "jni/id_cache.h"
+
+static jclass s_software_keyboard_class;
+static jclass s_keyboard_config_class;
+static jclass s_keyboard_data_class;
+static jmethodID s_swkbd_execute_normal;
+static jmethodID s_swkbd_execute_inline;
+
+namespace SoftwareKeyboard {
+
+static jobject ToJKeyboardParams(const Core::Frontend::KeyboardInitializeParameters& config) {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ jobject object = env->AllocObject(s_keyboard_config_class);
+
+ env->SetObjectField(object,
+ env->GetFieldID(s_keyboard_config_class, "ok_text", "Ljava/lang/String;"),
+ ToJString(env, config.ok_text));
+ env->SetObjectField(
+ object, env->GetFieldID(s_keyboard_config_class, "header_text", "Ljava/lang/String;"),
+ ToJString(env, config.header_text));
+ env->SetObjectField(object,
+ env->GetFieldID(s_keyboard_config_class, "sub_text", "Ljava/lang/String;"),
+ ToJString(env, config.sub_text));
+ env->SetObjectField(
+ object, env->GetFieldID(s_keyboard_config_class, "guide_text", "Ljava/lang/String;"),
+ ToJString(env, config.guide_text));
+ env->SetObjectField(
+ object, env->GetFieldID(s_keyboard_config_class, "initial_text", "Ljava/lang/String;"),
+ ToJString(env, config.initial_text));
+ env->SetShortField(object,
+ env->GetFieldID(s_keyboard_config_class, "left_optional_symbol_key", "S"),
+ static_cast<jshort>(config.left_optional_symbol_key));
+ env->SetShortField(object,
+ env->GetFieldID(s_keyboard_config_class, "right_optional_symbol_key", "S"),
+ static_cast<jshort>(config.right_optional_symbol_key));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "max_text_length", "I"),
+ static_cast<jint>(config.max_text_length));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "min_text_length", "I"),
+ static_cast<jint>(config.min_text_length));
+ env->SetIntField(object,
+ env->GetFieldID(s_keyboard_config_class, "initial_cursor_position", "I"),
+ static_cast<jint>(config.initial_cursor_position));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "type", "I"),
+ static_cast<jint>(config.type));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "password_mode", "I"),
+ static_cast<jint>(config.password_mode));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "text_draw_type", "I"),
+ static_cast<jint>(config.text_draw_type));
+ env->SetIntField(object, env->GetFieldID(s_keyboard_config_class, "key_disable_flags", "I"),
+ static_cast<jint>(config.key_disable_flags.raw));
+ env->SetBooleanField(object,
+ env->GetFieldID(s_keyboard_config_class, "use_blur_background", "Z"),
+ static_cast<jboolean>(config.use_blur_background));
+ env->SetBooleanField(object,
+ env->GetFieldID(s_keyboard_config_class, "enable_backspace_button", "Z"),
+ static_cast<jboolean>(config.enable_backspace_button));
+ env->SetBooleanField(object,
+ env->GetFieldID(s_keyboard_config_class, "enable_return_button", "Z"),
+ static_cast<jboolean>(config.enable_return_button));
+ env->SetBooleanField(object,
+ env->GetFieldID(s_keyboard_config_class, "disable_cancel_button", "Z"),
+ static_cast<jboolean>(config.disable_cancel_button));
+
+ return object;
+}
+
+AndroidKeyboard::ResultData AndroidKeyboard::ResultData::CreateFromFrontend(jobject object) {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ const jstring string = reinterpret_cast<jstring>(env->GetObjectField(
+ object, env->GetFieldID(s_keyboard_data_class, "text", "Ljava/lang/String;")));
+ return ResultData{GetJString(env, string),
+ static_cast<Service::AM::Applets::SwkbdResult>(env->GetIntField(
+ object, env->GetFieldID(s_keyboard_data_class, "result", "I")))};
+}
+
+AndroidKeyboard::~AndroidKeyboard() = default;
+
+void AndroidKeyboard::InitializeKeyboard(
+ bool is_inline, Core::Frontend::KeyboardInitializeParameters initialize_parameters,
+ SubmitNormalCallback submit_normal_callback_, SubmitInlineCallback submit_inline_callback_) {
+ if (is_inline) {
+ LOG_WARNING(
+ Frontend,
+ "(STUBBED) called, backend requested to initialize the inline software keyboard.");
+
+ submit_inline_callback = std::move(submit_inline_callback_);
+ } else {
+ LOG_WARNING(
+ Frontend,
+ "(STUBBED) called, backend requested to initialize the normal software keyboard.");
+
+ submit_normal_callback = std::move(submit_normal_callback_);
+ }
+
+ parameters = std::move(initialize_parameters);
+
+ LOG_INFO(Frontend,
+ "\nKeyboardInitializeParameters:"
+ "\nok_text={}"
+ "\nheader_text={}"
+ "\nsub_text={}"
+ "\nguide_text={}"
+ "\ninitial_text={}"
+ "\nmax_text_length={}"
+ "\nmin_text_length={}"
+ "\ninitial_cursor_position={}"
+ "\ntype={}"
+ "\npassword_mode={}"
+ "\ntext_draw_type={}"
+ "\nkey_disable_flags={}"
+ "\nuse_blur_background={}"
+ "\nenable_backspace_button={}"
+ "\nenable_return_button={}"
+ "\ndisable_cancel_button={}",
+ Common::UTF16ToUTF8(parameters.ok_text), Common::UTF16ToUTF8(parameters.header_text),
+ Common::UTF16ToUTF8(parameters.sub_text), Common::UTF16ToUTF8(parameters.guide_text),
+ Common::UTF16ToUTF8(parameters.initial_text), parameters.max_text_length,
+ parameters.min_text_length, parameters.initial_cursor_position, parameters.type,
+ parameters.password_mode, parameters.text_draw_type, parameters.key_disable_flags.raw,
+ parameters.use_blur_background, parameters.enable_backspace_button,
+ parameters.enable_return_button, parameters.disable_cancel_button);
+}
+
+void AndroidKeyboard::ShowNormalKeyboard() const {
+ LOG_DEBUG(Frontend, "called, backend requested to show the normal software keyboard.");
+
+ ResultData data{};
+
+ // Pivot to a new thread, as we cannot call GetEnvForThread() from a Fiber.
+ std::thread([&] {
+ data = ResultData::CreateFromFrontend(IDCache::GetEnvForThread()->CallStaticObjectMethod(
+ s_software_keyboard_class, s_swkbd_execute_normal, ToJKeyboardParams(parameters)));
+ }).join();
+
+ SubmitNormalText(data);
+}
+
+void AndroidKeyboard::ShowTextCheckDialog(
+ Service::AM::Applets::SwkbdTextCheckResult text_check_result,
+ std::u16string text_check_message) const {
+ LOG_WARNING(Frontend, "(STUBBED) called, backend requested to show the text check dialog.");
+}
+
+void AndroidKeyboard::ShowInlineKeyboard(
+ Core::Frontend::InlineAppearParameters appear_parameters) const {
+ LOG_WARNING(Frontend,
+ "(STUBBED) called, backend requested to show the inline software keyboard.");
+
+ LOG_INFO(Frontend,
+ "\nInlineAppearParameters:"
+ "\nmax_text_length={}"
+ "\nmin_text_length={}"
+ "\nkey_top_scale_x={}"
+ "\nkey_top_scale_y={}"
+ "\nkey_top_translate_x={}"
+ "\nkey_top_translate_y={}"
+ "\ntype={}"
+ "\nkey_disable_flags={}"
+ "\nkey_top_as_floating={}"
+ "\nenable_backspace_button={}"
+ "\nenable_return_button={}"
+ "\ndisable_cancel_button={}",
+ appear_parameters.max_text_length, appear_parameters.min_text_length,
+ appear_parameters.key_top_scale_x, appear_parameters.key_top_scale_y,
+ appear_parameters.key_top_translate_x, appear_parameters.key_top_translate_y,
+ appear_parameters.type, appear_parameters.key_disable_flags.raw,
+ appear_parameters.key_top_as_floating, appear_parameters.enable_backspace_button,
+ appear_parameters.enable_return_button, appear_parameters.disable_cancel_button);
+
+ // Pivot to a new thread, as we cannot call GetEnvForThread() from a Fiber.
+ m_is_inline_active = true;
+ std::thread([&] {
+ IDCache::GetEnvForThread()->CallStaticVoidMethod(
+ s_software_keyboard_class, s_swkbd_execute_inline, ToJKeyboardParams(parameters));
+ }).join();
+}
+
+void AndroidKeyboard::HideInlineKeyboard() const {
+ LOG_WARNING(Frontend,
+ "(STUBBED) called, backend requested to hide the inline software keyboard.");
+}
+
+void AndroidKeyboard::InlineTextChanged(
+ Core::Frontend::InlineTextParameters text_parameters) const {
+ LOG_WARNING(Frontend,
+ "(STUBBED) called, backend requested to change the inline keyboard text.");
+
+ LOG_INFO(Frontend,
+ "\nInlineTextParameters:"
+ "\ninput_text={}"
+ "\ncursor_position={}",
+ Common::UTF16ToUTF8(text_parameters.input_text), text_parameters.cursor_position);
+
+ submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString,
+ text_parameters.input_text, text_parameters.cursor_position);
+}
+
+void AndroidKeyboard::ExitKeyboard() const {
+ LOG_WARNING(Frontend, "(STUBBED) called, backend requested to exit the software keyboard.");
+}
+
+void AndroidKeyboard::SubmitInlineKeyboardText(std::u16string submitted_text) {
+ if (!m_is_inline_active) {
+ return;
+ }
+
+ m_current_text += submitted_text;
+
+ submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString, m_current_text,
+ m_current_text.size());
+}
+
+void AndroidKeyboard::SubmitInlineKeyboardInput(int key_code) {
+ static constexpr int KEYCODE_BACK = 4;
+ static constexpr int KEYCODE_ENTER = 66;
+ static constexpr int KEYCODE_DEL = 67;
+
+ if (!m_is_inline_active) {
+ return;
+ }
+
+ switch (key_code) {
+ case KEYCODE_BACK:
+ case KEYCODE_ENTER:
+ m_is_inline_active = false;
+ submit_inline_callback(Service::AM::Applets::SwkbdReplyType::DecidedEnter, m_current_text,
+ static_cast<s32>(m_current_text.size()));
+ break;
+ case KEYCODE_DEL:
+ m_current_text.pop_back();
+ submit_inline_callback(Service::AM::Applets::SwkbdReplyType::ChangedString, m_current_text,
+ m_current_text.size());
+ break;
+ }
+}
+
+void AndroidKeyboard::SubmitNormalText(const ResultData& data) const {
+ submit_normal_callback(data.result, Common::UTF8ToUTF16(data.text), true);
+}
+
+void InitJNI(JNIEnv* env) {
+ s_software_keyboard_class = reinterpret_cast<jclass>(
+ env->NewGlobalRef(env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard")));
+ s_keyboard_config_class = reinterpret_cast<jclass>(env->NewGlobalRef(
+ env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig")));
+ s_keyboard_data_class = reinterpret_cast<jclass>(env->NewGlobalRef(
+ env->FindClass("org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardData")));
+
+ s_swkbd_execute_normal = env->GetStaticMethodID(
+ s_software_keyboard_class, "executeNormal",
+ "(Lorg/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig;)Lorg/yuzu/yuzu_emu/"
+ "applets/keyboard/SoftwareKeyboard$KeyboardData;");
+ s_swkbd_execute_inline = env->GetStaticMethodID(
+ s_software_keyboard_class, "executeInline",
+ "(Lorg/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard$KeyboardConfig;)V");
+}
+
+void CleanupJNI(JNIEnv* env) {
+ env->DeleteGlobalRef(s_software_keyboard_class);
+ env->DeleteGlobalRef(s_keyboard_config_class);
+ env->DeleteGlobalRef(s_keyboard_data_class);
+}
+
+} // namespace SoftwareKeyboard
diff --git a/src/android/app/src/main/jni/applets/software_keyboard.h b/src/android/app/src/main/jni/applets/software_keyboard.h
new file mode 100644
index 000000000..b2fb59b68
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/software_keyboard.h
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <jni.h>
+
+#include "core/frontend/applets/software_keyboard.h"
+
+namespace SoftwareKeyboard {
+
+class AndroidKeyboard final : public Core::Frontend::SoftwareKeyboardApplet {
+public:
+ ~AndroidKeyboard() override;
+
+ void Close() const override {
+ ExitKeyboard();
+ }
+
+ void InitializeKeyboard(bool is_inline,
+ Core::Frontend::KeyboardInitializeParameters initialize_parameters,
+ SubmitNormalCallback submit_normal_callback_,
+ SubmitInlineCallback submit_inline_callback_) override;
+
+ void ShowNormalKeyboard() const override;
+
+ void ShowTextCheckDialog(Service::AM::Applets::SwkbdTextCheckResult text_check_result,
+ std::u16string text_check_message) const override;
+
+ void ShowInlineKeyboard(
+ Core::Frontend::InlineAppearParameters appear_parameters) const override;
+
+ void HideInlineKeyboard() const override;
+
+ void InlineTextChanged(Core::Frontend::InlineTextParameters text_parameters) const override;
+
+ void ExitKeyboard() const override;
+
+ void SubmitInlineKeyboardText(std::u16string submitted_text);
+
+ void SubmitInlineKeyboardInput(int key_code);
+
+private:
+ struct ResultData {
+ static ResultData CreateFromFrontend(jobject object);
+
+ std::string text;
+ Service::AM::Applets::SwkbdResult result{};
+ };
+
+ void SubmitNormalText(const ResultData& result) const;
+
+ Core::Frontend::KeyboardInitializeParameters parameters{};
+
+ mutable SubmitNormalCallback submit_normal_callback;
+ mutable SubmitInlineCallback submit_inline_callback;
+
+private:
+ mutable bool m_is_inline_active{};
+ std::u16string m_current_text;
+};
+
+// Should be called in JNI_Load
+void InitJNI(JNIEnv* env);
+
+// Should be called in JNI_Unload
+void CleanupJNI(JNIEnv* env);
+
+} // namespace SoftwareKeyboard
+
+// Native function calls
+extern "C" {
+JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateFilters(
+ JNIEnv* env, jclass clazz, jstring text);
+
+JNIEXPORT jobject JNICALL Java_org_citra_citra_1emu_applets_SoftwareKeyboard_ValidateInput(
+ JNIEnv* env, jclass clazz, jstring text);
+}
diff --git a/src/android/app/src/main/jni/config.cpp b/src/android/app/src/main/jni/config.cpp
new file mode 100644
index 000000000..2d622a048
--- /dev/null
+++ b/src/android/app/src/main/jni/config.cpp
@@ -0,0 +1,297 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <memory>
+#include <optional>
+#include <sstream>
+
+#include <INIReader.h>
+#include "common/fs/file.h"
+#include "common/fs/fs.h"
+#include "common/fs/path_util.h"
+#include "common/logging/log.h"
+#include "common/settings.h"
+#include "core/hle/service/acc/profile_manager.h"
+#include "input_common/main.h"
+#include "jni/config.h"
+#include "jni/default_ini.h"
+
+namespace FS = Common::FS;
+
+Config::Config(std::optional<std::filesystem::path> config_path)
+ : config_loc{config_path.value_or(FS::GetYuzuPath(FS::YuzuPath::ConfigDir) / "config.ini")},
+ config{std::make_unique<INIReader>(FS::PathToUTF8String(config_loc))} {
+ Reload();
+}
+
+Config::~Config() = default;
+
+bool Config::LoadINI(const std::string& default_contents, bool retry) {
+ const auto config_loc_str = FS::PathToUTF8String(config_loc);
+ if (config->ParseError() < 0) {
+ if (retry) {
+ LOG_WARNING(Config, "Failed to load {}. Creating file from defaults...",
+ config_loc_str);
+
+ void(FS::CreateParentDir(config_loc));
+ void(FS::WriteStringToFile(config_loc, FS::FileType::TextFile, default_contents));
+
+ config = std::make_unique<INIReader>(config_loc_str);
+
+ return LoadINI(default_contents, false);
+ }
+ LOG_ERROR(Config, "Failed.");
+ return false;
+ }
+ LOG_INFO(Config, "Successfully loaded {}", config_loc_str);
+ return true;
+}
+
+template <>
+void Config::ReadSetting(const std::string& group, Settings::Setting<std::string>& setting) {
+ std::string setting_value = config->Get(group, setting.GetLabel(), setting.GetDefault());
+ if (setting_value.empty()) {
+ setting_value = setting.GetDefault();
+ }
+ setting = std::move(setting_value);
+}
+
+template <>
+void Config::ReadSetting(const std::string& group, Settings::Setting<bool>& setting) {
+ setting = config->GetBoolean(group, setting.GetLabel(), setting.GetDefault());
+}
+
+template <typename Type, bool ranged>
+void Config::ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting) {
+ setting = static_cast<Type>(
+ config->GetInteger(group, setting.GetLabel(), static_cast<long>(setting.GetDefault())));
+}
+
+void Config::ReadValues() {
+ ReadSetting("ControlsGeneral", Settings::values.mouse_enabled);
+ ReadSetting("ControlsGeneral", Settings::values.touch_device);
+ ReadSetting("ControlsGeneral", Settings::values.keyboard_enabled);
+ ReadSetting("ControlsGeneral", Settings::values.debug_pad_enabled);
+ ReadSetting("ControlsGeneral", Settings::values.vibration_enabled);
+ ReadSetting("ControlsGeneral", Settings::values.enable_accurate_vibrations);
+ ReadSetting("ControlsGeneral", Settings::values.motion_enabled);
+ Settings::values.touchscreen.enabled =
+ config->GetBoolean("ControlsGeneral", "touch_enabled", true);
+ Settings::values.touchscreen.rotation_angle =
+ config->GetInteger("ControlsGeneral", "touch_angle", 0);
+ Settings::values.touchscreen.diameter_x =
+ config->GetInteger("ControlsGeneral", "touch_diameter_x", 15);
+ Settings::values.touchscreen.diameter_y =
+ config->GetInteger("ControlsGeneral", "touch_diameter_y", 15);
+
+ int num_touch_from_button_maps =
+ config->GetInteger("ControlsGeneral", "touch_from_button_map", 0);
+ if (num_touch_from_button_maps > 0) {
+ for (int i = 0; i < num_touch_from_button_maps; ++i) {
+ Settings::TouchFromButtonMap map;
+ map.name = config->Get("ControlsGeneral",
+ std::string("touch_from_button_maps_") + std::to_string(i) +
+ std::string("_name"),
+ "default");
+ const int num_touch_maps = config->GetInteger(
+ "ControlsGeneral",
+ std::string("touch_from_button_maps_") + std::to_string(i) + std::string("_count"),
+ 0);
+ map.buttons.reserve(num_touch_maps);
+
+ for (int j = 0; j < num_touch_maps; ++j) {
+ std::string touch_mapping =
+ config->Get("ControlsGeneral",
+ std::string("touch_from_button_maps_") + std::to_string(i) +
+ std::string("_bind_") + std::to_string(j),
+ "");
+ map.buttons.emplace_back(std::move(touch_mapping));
+ }
+
+ Settings::values.touch_from_button_maps.emplace_back(std::move(map));
+ }
+ } else {
+ Settings::values.touch_from_button_maps.emplace_back(
+ Settings::TouchFromButtonMap{"default", {}});
+ num_touch_from_button_maps = 1;
+ }
+ Settings::values.touch_from_button_map_index = std::clamp(
+ Settings::values.touch_from_button_map_index.GetValue(), 0, num_touch_from_button_maps - 1);
+
+ ReadSetting("ControlsGeneral", Settings::values.udp_input_servers);
+
+ // Data Storage
+ ReadSetting("Data Storage", Settings::values.use_virtual_sd);
+ FS::SetYuzuPath(FS::YuzuPath::NANDDir,
+ config->Get("Data Storage", "nand_directory",
+ FS::GetYuzuPathString(FS::YuzuPath::NANDDir)));
+ FS::SetYuzuPath(FS::YuzuPath::SDMCDir,
+ config->Get("Data Storage", "sdmc_directory",
+ FS::GetYuzuPathString(FS::YuzuPath::SDMCDir)));
+ FS::SetYuzuPath(FS::YuzuPath::LoadDir,
+ config->Get("Data Storage", "load_directory",
+ FS::GetYuzuPathString(FS::YuzuPath::LoadDir)));
+ FS::SetYuzuPath(FS::YuzuPath::DumpDir,
+ config->Get("Data Storage", "dump_directory",
+ FS::GetYuzuPathString(FS::YuzuPath::DumpDir)));
+ ReadSetting("Data Storage", Settings::values.gamecard_inserted);
+ ReadSetting("Data Storage", Settings::values.gamecard_current_game);
+ ReadSetting("Data Storage", Settings::values.gamecard_path);
+
+ // System
+ ReadSetting("System", Settings::values.current_user);
+ Settings::values.current_user = std::clamp<int>(Settings::values.current_user.GetValue(), 0,
+ Service::Account::MAX_USERS - 1);
+
+ // Disable docked mode by default on Android
+ Settings::values.use_docked_mode = config->GetBoolean("System", "use_docked_mode", false);
+
+ const auto rng_seed_enabled = config->GetBoolean("System", "rng_seed_enabled", false);
+ if (rng_seed_enabled) {
+ Settings::values.rng_seed.SetValue(config->GetInteger("System", "rng_seed", 0));
+ } else {
+ Settings::values.rng_seed.SetValue(std::nullopt);
+ }
+
+ const auto custom_rtc_enabled = config->GetBoolean("System", "custom_rtc_enabled", false);
+ if (custom_rtc_enabled) {
+ Settings::values.custom_rtc = config->GetInteger("System", "custom_rtc", 0);
+ } else {
+ Settings::values.custom_rtc = std::nullopt;
+ }
+
+ ReadSetting("System", Settings::values.language_index);
+ ReadSetting("System", Settings::values.region_index);
+ ReadSetting("System", Settings::values.time_zone_index);
+ ReadSetting("System", Settings::values.sound_index);
+
+ // Core
+ ReadSetting("Core", Settings::values.use_multi_core);
+ ReadSetting("Core", Settings::values.use_unsafe_extended_memory_layout);
+
+ // Cpu
+ ReadSetting("Cpu", Settings::values.cpu_accuracy);
+ ReadSetting("Cpu", Settings::values.cpu_debug_mode);
+ ReadSetting("Cpu", Settings::values.cpuopt_page_tables);
+ ReadSetting("Cpu", Settings::values.cpuopt_block_linking);
+ ReadSetting("Cpu", Settings::values.cpuopt_return_stack_buffer);
+ ReadSetting("Cpu", Settings::values.cpuopt_fast_dispatcher);
+ ReadSetting("Cpu", Settings::values.cpuopt_context_elimination);
+ ReadSetting("Cpu", Settings::values.cpuopt_const_prop);
+ ReadSetting("Cpu", Settings::values.cpuopt_misc_ir);
+ ReadSetting("Cpu", Settings::values.cpuopt_reduce_misalign_checks);
+ ReadSetting("Cpu", Settings::values.cpuopt_fastmem);
+ ReadSetting("Cpu", Settings::values.cpuopt_fastmem_exclusives);
+ ReadSetting("Cpu", Settings::values.cpuopt_recompile_exclusives);
+ ReadSetting("Cpu", Settings::values.cpuopt_ignore_memory_aborts);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_unfuse_fma);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_reduce_fp_error);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_standard_fpcr);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_inaccurate_nan);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_fastmem_check);
+ ReadSetting("Cpu", Settings::values.cpuopt_unsafe_ignore_global_monitor);
+
+ // Renderer
+ ReadSetting("Renderer", Settings::values.renderer_backend);
+ ReadSetting("Renderer", Settings::values.renderer_debug);
+ ReadSetting("Renderer", Settings::values.renderer_shader_feedback);
+ ReadSetting("Renderer", Settings::values.enable_nsight_aftermath);
+ ReadSetting("Renderer", Settings::values.disable_shader_loop_safety_checks);
+ ReadSetting("Renderer", Settings::values.vulkan_device);
+
+ ReadSetting("Renderer", Settings::values.resolution_setup);
+ ReadSetting("Renderer", Settings::values.scaling_filter);
+ ReadSetting("Renderer", Settings::values.fsr_sharpening_slider);
+ ReadSetting("Renderer", Settings::values.anti_aliasing);
+ ReadSetting("Renderer", Settings::values.fullscreen_mode);
+ ReadSetting("Renderer", Settings::values.aspect_ratio);
+ ReadSetting("Renderer", Settings::values.max_anisotropy);
+ ReadSetting("Renderer", Settings::values.use_speed_limit);
+ ReadSetting("Renderer", Settings::values.speed_limit);
+ ReadSetting("Renderer", Settings::values.use_disk_shader_cache);
+ ReadSetting("Renderer", Settings::values.use_asynchronous_gpu_emulation);
+ ReadSetting("Renderer", Settings::values.vsync_mode);
+ ReadSetting("Renderer", Settings::values.shader_backend);
+ ReadSetting("Renderer", Settings::values.use_asynchronous_shaders);
+ ReadSetting("Renderer", Settings::values.nvdec_emulation);
+ ReadSetting("Renderer", Settings::values.use_fast_gpu_time);
+ ReadSetting("Renderer", Settings::values.use_vulkan_driver_pipeline_cache);
+
+ ReadSetting("Renderer", Settings::values.bg_red);
+ ReadSetting("Renderer", Settings::values.bg_green);
+ ReadSetting("Renderer", Settings::values.bg_blue);
+
+ // Use GPU accuracy normal by default on Android
+ Settings::values.gpu_accuracy = static_cast<Settings::GPUAccuracy>(config->GetInteger(
+ "Renderer", "gpu_accuracy", static_cast<u32>(Settings::GPUAccuracy::Normal)));
+
+ // Use GPU default anisotropic filtering on Android
+ Settings::values.max_anisotropy = config->GetInteger("Renderer", "max_anisotropy", 1);
+
+ // Disable ASTC compute by default on Android
+ Settings::values.accelerate_astc = config->GetBoolean("Renderer", "accelerate_astc", false);
+
+ // Enable asynchronous presentation by default on Android
+ Settings::values.async_presentation =
+ config->GetBoolean("Renderer", "async_presentation", true);
+
+ // Enable force_max_clock by default on Android
+ Settings::values.renderer_force_max_clock =
+ config->GetBoolean("Renderer", "force_max_clock", true);
+
+ // Audio
+ ReadSetting("Audio", Settings::values.sink_id);
+ ReadSetting("Audio", Settings::values.audio_output_device_id);
+ ReadSetting("Audio", Settings::values.volume);
+
+ // Miscellaneous
+ // log_filter has a different default here than from common
+ Settings::values.log_filter = "*:Info";
+ ReadSetting("Miscellaneous", Settings::values.use_dev_keys);
+
+ // Debugging
+ Settings::values.record_frame_times =
+ config->GetBoolean("Debugging", "record_frame_times", false);
+ ReadSetting("Debugging", Settings::values.dump_exefs);
+ ReadSetting("Debugging", Settings::values.dump_nso);
+ ReadSetting("Debugging", Settings::values.enable_fs_access_log);
+ ReadSetting("Debugging", Settings::values.reporting_services);
+ ReadSetting("Debugging", Settings::values.quest_flag);
+ ReadSetting("Debugging", Settings::values.use_debug_asserts);
+ ReadSetting("Debugging", Settings::values.use_auto_stub);
+ ReadSetting("Debugging", Settings::values.disable_macro_jit);
+ ReadSetting("Debugging", Settings::values.disable_macro_hle);
+ ReadSetting("Debugging", Settings::values.use_gdbstub);
+ ReadSetting("Debugging", Settings::values.gdbstub_port);
+
+ const auto title_list = config->Get("AddOns", "title_ids", "");
+ std::stringstream ss(title_list);
+ std::string line;
+ while (std::getline(ss, line, '|')) {
+ const auto title_id = std::stoul(line, nullptr, 16);
+ const auto disabled_list = config->Get("AddOns", "disabled_" + line, "");
+
+ std::stringstream inner_ss(disabled_list);
+ std::string inner_line;
+ std::vector<std::string> out;
+ while (std::getline(inner_ss, inner_line, '|')) {
+ out.push_back(inner_line);
+ }
+
+ Settings::values.disabled_addons.insert_or_assign(title_id, out);
+ }
+
+ // Web Service
+ ReadSetting("WebService", Settings::values.enable_telemetry);
+ ReadSetting("WebService", Settings::values.web_api_url);
+ ReadSetting("WebService", Settings::values.yuzu_username);
+ ReadSetting("WebService", Settings::values.yuzu_token);
+
+ // Network
+ ReadSetting("Network", Settings::values.network_interface);
+}
+
+void Config::Reload() {
+ LoadINI(DefaultINI::android_config_file);
+ ReadValues();
+}
diff --git a/src/android/app/src/main/jni/config.h b/src/android/app/src/main/jni/config.h
new file mode 100644
index 000000000..0d7d6e94d
--- /dev/null
+++ b/src/android/app/src/main/jni/config.h
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+
+#include "common/settings.h"
+
+class INIReader;
+
+class Config {
+ std::filesystem::path config_loc;
+ std::unique_ptr<INIReader> config;
+
+ bool LoadINI(const std::string& default_contents = "", bool retry = true);
+ void ReadValues();
+
+public:
+ explicit Config(std::optional<std::filesystem::path> config_path = std::nullopt);
+ ~Config();
+
+ void Reload();
+
+private:
+ /**
+ * Applies a value read from the sdl2_config to a Setting.
+ *
+ * @param group The name of the INI group
+ * @param setting The yuzu setting to modify
+ */
+ template <typename Type, bool ranged>
+ void ReadSetting(const std::string& group, Settings::Setting<Type, ranged>& setting);
+};
diff --git a/src/android/app/src/main/jni/default_ini.h b/src/android/app/src/main/jni/default_ini.h
new file mode 100644
index 000000000..c5dfaff54
--- /dev/null
+++ b/src/android/app/src/main/jni/default_ini.h
@@ -0,0 +1,507 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+namespace DefaultINI {
+
+const char* android_config_file = R"(
+
+[ControlsP0]
+# The input devices and parameters for each Switch native input
+# The config section determines the player number where the config will be applied on. For example "ControlsP0", "ControlsP1", ...
+# It should be in the format of "engine:[engine_name],[param1]:[value1],[param2]:[value2]..."
+# Escape characters $0 (for ':'), $1 (for ',') and $2 (for '$') can be used in values
+
+# Indicates if this player should be connected at boot
+connected=
+
+# for button input, the following devices are available:
+# - "keyboard" (default) for keyboard input. Required parameters:
+# - "code": the code of the key to bind
+# - "sdl" for joystick input using SDL. Required parameters:
+# - "guid": SDL identification GUID of the joystick
+# - "port": the index of the joystick to bind
+# - "button"(optional): the index of the button to bind
+# - "hat"(optional): the index of the hat to bind as direction buttons
+# - "axis"(optional): the index of the axis to bind
+# - "direction"(only used for hat): the direction name of the hat to bind. Can be "up", "down", "left" or "right"
+# - "threshold"(only used for axis): a float value in (-1.0, 1.0) which the button is
+# triggered if the axis value crosses
+# - "direction"(only used for axis): "+" means the button is triggered when the axis value
+# is greater than the threshold; "-" means the button is triggered when the axis value
+# is smaller than the threshold
+button_a=
+button_b=
+button_x=
+button_y=
+button_lstick=
+button_rstick=
+button_l=
+button_r=
+button_zl=
+button_zr=
+button_plus=
+button_minus=
+button_dleft=
+button_dup=
+button_dright=
+button_ddown=
+button_lstick_left=
+button_lstick_up=
+button_lstick_right=
+button_lstick_down=
+button_sl=
+button_sr=
+button_home=
+button_screenshot=
+
+# for analog input, the following devices are available:
+# - "analog_from_button" (default) for emulating analog input from direction buttons. Required parameters:
+# - "up", "down", "left", "right": sub-devices for each direction.
+# Should be in the format as a button input devices using escape characters, for example, "engine$0keyboard$1code$00"
+# - "modifier": sub-devices as a modifier.
+# - "modifier_scale": a float number representing the applied modifier scale to the analog input.
+# Must be in range of 0.0-1.0. Defaults to 0.5
+# - "sdl" for joystick input using SDL. Required parameters:
+# - "guid": SDL identification GUID of the joystick
+# - "port": the index of the joystick to bind
+# - "axis_x": the index of the axis to bind as x-axis (default to 0)
+# - "axis_y": the index of the axis to bind as y-axis (default to 1)
+lstick=
+rstick=
+
+# for motion input, the following devices are available:
+# - "keyboard" (default) for emulating random motion input from buttons. Required parameters:
+# - "code": the code of the key to bind
+# - "sdl" for motion input using SDL. Required parameters:
+# - "guid": SDL identification GUID of the joystick
+# - "port": the index of the joystick to bind
+# - "motion": the index of the motion sensor to bind
+# - "cemuhookudp" for motion input using Cemu Hook protocol. Required parameters:
+# - "guid": the IP address of the cemu hook server encoded to a hex string. for example 192.168.0.1 = "c0a80001"
+# - "port": the port of the cemu hook server
+# - "pad": the index of the joystick
+# - "motion": the index of the motion sensor of the joystick to bind
+motionleft=
+motionright=
+
+[ControlsGeneral]
+# To use the debug_pad, prepend `debug_pad_` before each button setting above.
+# i.e. debug_pad_button_a=
+
+# Enable debug pad inputs to the guest
+# 0 (default): Disabled, 1: Enabled
+debug_pad_enabled =
+
+# Whether to enable or disable vibration
+# 0: Disabled, 1 (default): Enabled
+vibration_enabled=
+
+# Whether to enable or disable accurate vibrations
+# 0 (default): Disabled, 1: Enabled
+enable_accurate_vibrations=
+
+# Enables controller motion inputs
+# 0: Disabled, 1 (default): Enabled
+motion_enabled =
+
+# Defines the udp device's touch screen coordinate system for cemuhookudp devices
+# - "min_x", "min_y", "max_x", "max_y"
+touch_device=
+
+# for mapping buttons to touch inputs.
+#touch_from_button_map=1
+#touch_from_button_maps_0_name=default
+#touch_from_button_maps_0_count=2
+#touch_from_button_maps_0_bind_0=foo
+#touch_from_button_maps_0_bind_1=bar
+# etc.
+
+# List of Cemuhook UDP servers, delimited by ','.
+# Default: 127.0.0.1:26760
+# Example: 127.0.0.1:26760,123.4.5.67:26761
+udp_input_servers =
+
+# Enable controlling an axis via a mouse input.
+# 0 (default): Off, 1: On
+mouse_panning =
+
+# Set mouse sensitivity.
+# Default: 1.0
+mouse_panning_sensitivity =
+
+# Emulate an analog control stick from keyboard inputs.
+# 0 (default): Disabled, 1: Enabled
+emulate_analog_keyboard =
+
+# Enable mouse inputs to the guest
+# 0 (default): Disabled, 1: Enabled
+mouse_enabled =
+
+# Enable keyboard inputs to the guest
+# 0 (default): Disabled, 1: Enabled
+keyboard_enabled =
+
+[Core]
+# Whether to use multi-core for CPU emulation
+# 0: Disabled, 1 (default): Enabled
+use_multi_core =
+
+# Enable unsafe extended guest system memory layout (8GB DRAM)
+# 0 (default): Disabled, 1: Enabled
+use_unsafe_extended_memory_layout =
+
+[Cpu]
+# Adjusts various optimizations.
+# Auto-select mode enables choice unsafe optimizations.
+# Accurate enables only safe optimizations.
+# Unsafe allows any unsafe optimizations.
+# 0 (default): Auto-select, 1: Accurate, 2: Enable unsafe optimizations
+cpu_accuracy =
+
+# Allow disabling safe optimizations.
+# 0 (default): Disabled, 1: Enabled
+cpu_debug_mode =
+
+# Enable inline page tables optimization (faster guest memory access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_page_tables =
+
+# Enable block linking CPU optimization (reduce block dispatcher use during predictable jumps)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_block_linking =
+
+# Enable return stack buffer CPU optimization (reduce block dispatcher use during predictable returns)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_return_stack_buffer =
+
+# Enable fast dispatcher CPU optimization (use a two-tiered dispatcher architecture)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_fast_dispatcher =
+
+# Enable context elimination CPU Optimization (reduce host memory use for guest context)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_context_elimination =
+
+# Enable constant propagation CPU optimization (basic IR optimization)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_const_prop =
+
+# Enable miscellaneous CPU optimizations (basic IR optimization)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_misc_ir =
+
+# Enable reduction of memory misalignment checks (reduce memory fallbacks for misaligned access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_reduce_misalign_checks =
+
+# Enable Host MMU Emulation (faster guest memory access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_fastmem =
+
+# Enable Host MMU Emulation for exclusive memory instructions (faster guest memory access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_fastmem_exclusives =
+
+# Enable fallback on failure of fastmem of exclusive memory instructions (faster guest memory access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_recompile_exclusives =
+
+# Enable optimization to ignore invalid memory accesses (faster guest memory access)
+# 0: Disabled, 1 (default): Enabled
+cpuopt_ignore_memory_aborts =
+
+# Enable unfuse FMA (improve performance on CPUs without FMA)
+# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_unfuse_fma =
+
+# Enable faster FRSQRTE and FRECPE
+# Only enabled if cpu_accuracy is set to Unsafe.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_reduce_fp_error =
+
+# Enable faster ASIMD instructions (32 bits only)
+# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_ignore_standard_fpcr =
+
+# Enable inaccurate NaN handling
+# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_inaccurate_nan =
+
+# Disable address space checks (64 bits only)
+# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_fastmem_check =
+
+# Enable faster exclusive instructions
+# Only enabled if cpu_accuracy is set to Unsafe. Automatically chosen with cpu_accuracy = Auto-select.
+# 0: Disabled, 1 (default): Enabled
+cpuopt_unsafe_ignore_global_monitor =
+
+[Renderer]
+# Which backend API to use.
+# 0: OpenGL (unsupported), 1 (default): Vulkan, 2: Null
+backend =
+
+# Whether to enable asynchronous presentation (Vulkan only)
+# 0: Off, 1 (default): On
+async_presentation =
+
+# Enable graphics API debugging mode.
+# 0 (default): Disabled, 1: Enabled
+force_max_clock =
+
+# Enable graphics API debugging mode.
+# 0 (default): Disabled, 1: Enabled
+debug =
+
+# Enable shader feedback.
+# 0 (default): Disabled, 1: Enabled
+renderer_shader_feedback =
+
+# Enable Nsight Aftermath crash dumps
+# 0 (default): Disabled, 1: Enabled
+nsight_aftermath =
+
+# Disable shader loop safety checks, executing the shader without loop logic changes
+# 0 (default): Disabled, 1: Enabled
+disable_shader_loop_safety_checks =
+
+# Which Vulkan physical device to use (defaults to 0)
+vulkan_device =
+
+# 0: 0.5x (360p/540p) [EXPERIMENTAL]
+# 1: 0.75x (540p/810p) [EXPERIMENTAL]
+# 2 (default): 1x (720p/1080p)
+# 3: 2x (1440p/2160p)
+# 4: 3x (2160p/3240p)
+# 5: 4x (2880p/4320p)
+# 6: 5x (3600p/5400p)
+# 7: 6x (4320p/6480p)
+resolution_setup =
+
+# Pixel filter to use when up- or down-sampling rendered frames.
+# 0: Nearest Neighbor
+# 1 (default): Bilinear
+# 2: Bicubic
+# 3: Gaussian
+# 4: ScaleForce
+# 5: AMD FidelityFX™️ Super Resolution [Vulkan Only]
+scaling_filter =
+
+# Anti-Aliasing (AA)
+# 0 (default): None, 1: FXAA
+anti_aliasing =
+
+# Whether to use fullscreen or borderless window mode
+# 0 (Windows default): Borderless window, 1 (All other default): Exclusive fullscreen
+fullscreen_mode =
+
+# Aspect ratio
+# 0: Default (16:9), 1: Force 4:3, 2: Force 21:9, 3: Force 16:10, 4: Stretch to Window
+aspect_ratio =
+
+# Anisotropic filtering
+# 0: Default, 1: 2x, 2: 4x, 3: 8x, 4: 16x
+max_anisotropy =
+
+# Whether to enable VSync or not.
+# OpenGL: Values other than 0 enable VSync
+# Vulkan: FIFO is selected if the requested mode is not supported by the driver.
+# FIFO (VSync) does not drop frames or exhibit tearing but is limited by the screen refresh rate.
+# FIFO Relaxed is similar to FIFO but allows tearing as it recovers from a slow down.
+# Mailbox can have lower latency than FIFO and does not tear but may drop frames.
+# Immediate (no synchronization) just presents whatever is available and can exhibit tearing.
+# 0: Immediate (Off), 1 (Default): Mailbox (On), 2: FIFO, 3: FIFO Relaxed
+use_vsync =
+
+# Selects the OpenGL shader backend. NV_gpu_program5 is required for GLASM. If NV_gpu_program5 is
+# not available and GLASM is selected, GLSL will be used.
+# 0: GLSL, 1 (default): GLASM, 2: SPIR-V
+shader_backend =
+
+# Whether to allow asynchronous shader building.
+# 0 (default): Off, 1: On
+use_asynchronous_shaders =
+
+# NVDEC emulation.
+# 0: Disabled, 1: CPU Decoding, 2 (default): GPU Decoding
+nvdec_emulation =
+
+# Accelerate ASTC texture decoding.
+# 0 (default): Off, 1: On
+accelerate_astc =
+
+# Turns on the speed limiter, which will limit the emulation speed to the desired speed limit value
+# 0: Off, 1: On (default)
+use_speed_limit =
+
+# Limits the speed of the game to run no faster than this value as a percentage of target speed
+# 1 - 9999: Speed limit as a percentage of target game speed. 100 (default)
+speed_limit =
+
+# Whether to use disk based shader cache
+# 0: Off, 1 (default): On
+use_disk_shader_cache =
+
+# Which gpu accuracy level to use
+# 0 (default): Normal, 1: High, 2: Extreme (Very slow)
+gpu_accuracy =
+
+# Whether to use asynchronous GPU emulation
+# 0 : Off (slow), 1 (default): On (fast)
+use_asynchronous_gpu_emulation =
+
+# Inform the guest that GPU operations completed more quickly than they did.
+# 0: Off, 1 (default): On
+use_fast_gpu_time =
+
+# Force unmodified buffers to be flushed, which can cost performance.
+# 0: Off (default), 1: On
+use_pessimistic_flushes =
+
+# Whether to use garbage collection or not for GPU caches.
+# 0 (default): Off, 1: On
+use_caches_gc =
+
+# The clear color for the renderer. What shows up on the sides of the bottom screen.
+# Must be in range of 0-255. Defaults to 0 for all.
+bg_red =
+bg_blue =
+bg_green =
+
+[Audio]
+# Which audio output engine to use.
+# auto (default): Auto-select
+# cubeb: Cubeb audio engine (if available)
+# sdl2: SDL2 audio engine (if available)
+# null: No audio output
+output_engine =
+
+# Which audio device to use.
+# auto (default): Auto-select
+output_device =
+
+# Output volume.
+# 100 (default): 100%, 0; mute
+volume =
+
+[Data Storage]
+# Whether to create a virtual SD card.
+# 1: Yes, 0 (default): No
+use_virtual_sd =
+
+# Whether or not to enable gamecard emulation
+# 1: Yes, 0 (default): No
+gamecard_inserted =
+
+# Whether or not the gamecard should be emulated as the current game
+# If 'gamecard_inserted' is 0 this setting is irrelevant
+# 1: Yes, 0 (default): No
+gamecard_current_game =
+
+# Path to an XCI file to use as the gamecard
+# If 'gamecard_inserted' is 0 this setting is irrelevant
+# If 'gamecard_current_game' is 1 this setting is irrelevant
+gamecard_path =
+
+[System]
+# Whether the system is docked
+# 1 (default): Yes, 0: No
+use_docked_mode =
+
+# Sets the seed for the RNG generator built into the switch
+# rng_seed will be ignored and randomly generated if rng_seed_enabled is false
+rng_seed_enabled =
+rng_seed =
+
+# Sets the current time (in seconds since 12:00 AM Jan 1, 1970) that will be used by the time service
+# This will auto-increment, with the time set being the time the game is started
+# This override will only occur if custom_rtc_enabled is true, otherwise the current time is used
+custom_rtc_enabled =
+custom_rtc =
+
+# Sets the systems language index
+# 0: Japanese, 1: English (default), 2: French, 3: German, 4: Italian, 5: Spanish, 6: Chinese,
+# 7: Korean, 8: Dutch, 9: Portuguese, 10: Russian, 11: Taiwanese, 12: British English, 13: Canadian French,
+# 14: Latin American Spanish, 15: Simplified Chinese, 16: Traditional Chinese, 17: Brazilian Portuguese
+language_index =
+
+# The system region that yuzu will use during emulation
+# -1: Auto-select (default), 0: Japan, 1: USA, 2: Europe, 3: Australia, 4: China, 5: Korea, 6: Taiwan
+region_index =
+
+# The system time zone that yuzu will use during emulation
+# 0: Auto-select (default), 1: Default (system archive value), Others: Index for specified time zone
+time_zone_index =
+
+# Sets the sound output mode.
+# 0: Mono, 1 (default): Stereo, 2: Surround
+sound_index =
+
+[Miscellaneous]
+# A filter which removes logs below a certain logging level.
+# Examples: *:Debug Kernel.SVC:Trace Service.*:Critical
+log_filter = *:Trace
+
+# Use developer keys
+# 0 (default): Disabled, 1: Enabled
+use_dev_keys =
+
+[Debugging]
+# Record frame time data, can be found in the log directory. Boolean value
+record_frame_times =
+# Determines whether or not yuzu will dump the ExeFS of all games it attempts to load while loading them
+dump_exefs=false
+# Determines whether or not yuzu will dump all NSOs it attempts to load while loading them
+dump_nso=false
+# Determines whether or not yuzu will save the filesystem access log.
+enable_fs_access_log=false
+# Enables verbose reporting services
+reporting_services =
+# Determines whether or not yuzu will report to the game that the emulated console is in Kiosk Mode
+# false: Retail/Normal Mode (default), true: Kiosk Mode
+quest_flag =
+# Determines whether debug asserts should be enabled, which will throw an exception on asserts.
+# false: Disabled (default), true: Enabled
+use_debug_asserts =
+# Determines whether unimplemented HLE service calls should be automatically stubbed.
+# false: Disabled (default), true: Enabled
+use_auto_stub =
+# Enables/Disables the macro JIT compiler
+disable_macro_jit=false
+# Determines whether to enable the GDB stub and wait for the debugger to attach before running.
+# false: Disabled (default), true: Enabled
+use_gdbstub=false
+# The port to use for the GDB server, if it is enabled.
+gdbstub_port=6543
+
+[WebService]
+# Whether or not to enable telemetry
+# 0: No, 1 (default): Yes
+enable_telemetry =
+# URL for Web API
+web_api_url = https://api.yuzu-emu.org
+# Username and token for yuzu Web Service
+# See https://profile.yuzu-emu.org/ for more info
+yuzu_username =
+yuzu_token =
+
+[Network]
+# Name of the network interface device to use with yuzu LAN play.
+# e.g. On *nix: 'enp7s0', 'wlp6s0u1u3u3', 'lo'
+# e.g. On Windows: 'Ethernet', 'Wi-Fi'
+network_interface =
+
+[AddOns]
+# Used to disable add-ons
+# List of title IDs of games that will have add-ons disabled (separated by '|'):
+title_ids =
+# For each title ID, have a key/value pair called `disabled_<title_id>` equal to the names of the add-ons to disable (sep. by '|')
+# e.x. disabled_0100000000010000 = Update|DLC <- disables Updates and DLC on Super Mario Odyssey
+)";
+} // namespace DefaultINI
diff --git a/src/android/app/src/main/jni/emu_window/emu_window.cpp b/src/android/app/src/main/jni/emu_window/emu_window.cpp
new file mode 100644
index 000000000..a890c6604
--- /dev/null
+++ b/src/android/app/src/main/jni/emu_window/emu_window.cpp
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include <android/native_window_jni.h>
+
+#include "common/logging/log.h"
+#include "input_common/drivers/touch_screen.h"
+#include "input_common/drivers/virtual_amiibo.h"
+#include "input_common/drivers/virtual_gamepad.h"
+#include "input_common/main.h"
+#include "jni/emu_window/emu_window.h"
+
+void EmuWindow_Android::OnSurfaceChanged(ANativeWindow* surface) {
+ window_info.render_surface = reinterpret_cast<void*>(surface);
+}
+
+void EmuWindow_Android::OnTouchPressed(int id, float x, float y) {
+ const auto [touch_x, touch_y] = MapToTouchScreen(x, y);
+ m_input_subsystem->GetTouchScreen()->TouchPressed(touch_x, touch_y, id);
+}
+
+void EmuWindow_Android::OnTouchMoved(int id, float x, float y) {
+ const auto [touch_x, touch_y] = MapToTouchScreen(x, y);
+ m_input_subsystem->GetTouchScreen()->TouchMoved(touch_x, touch_y, id);
+}
+
+void EmuWindow_Android::OnTouchReleased(int id) {
+ m_input_subsystem->GetTouchScreen()->TouchReleased(id);
+}
+
+void EmuWindow_Android::OnGamepadButtonEvent(int player_index, int button_id, bool pressed) {
+ m_input_subsystem->GetVirtualGamepad()->SetButtonState(player_index, button_id, pressed);
+}
+
+void EmuWindow_Android::OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y) {
+ m_input_subsystem->GetVirtualGamepad()->SetStickPosition(player_index, stick_id, x, y);
+}
+
+void EmuWindow_Android::OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x,
+ float gyro_y, float gyro_z, float accel_x,
+ float accel_y, float accel_z) {
+ m_input_subsystem->GetVirtualGamepad()->SetMotionState(
+ player_index, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z);
+}
+
+void EmuWindow_Android::OnReadNfcTag(std::span<u8> data) {
+ m_input_subsystem->GetVirtualAmiibo()->LoadAmiibo(data);
+}
+
+void EmuWindow_Android::OnRemoveNfcTag() {
+ m_input_subsystem->GetVirtualAmiibo()->CloseAmiibo();
+}
+
+EmuWindow_Android::EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem,
+ ANativeWindow* surface,
+ std::shared_ptr<Common::DynamicLibrary> driver_library)
+ : m_input_subsystem{input_subsystem}, m_driver_library{driver_library} {
+ LOG_INFO(Frontend, "initializing");
+
+ if (!surface) {
+ LOG_CRITICAL(Frontend, "surface is nullptr");
+ return;
+ }
+
+ m_window_width = ANativeWindow_getWidth(surface);
+ m_window_height = ANativeWindow_getHeight(surface);
+
+ // Ensures that we emulate with the correct aspect ratio.
+ UpdateCurrentFramebufferLayout(m_window_width, m_window_height);
+
+ window_info.type = Core::Frontend::WindowSystemType::Android;
+ window_info.render_surface = reinterpret_cast<void*>(surface);
+
+ m_input_subsystem->Initialize();
+}
+
+EmuWindow_Android::~EmuWindow_Android() {
+ m_input_subsystem->Shutdown();
+}
diff --git a/src/android/app/src/main/jni/emu_window/emu_window.h b/src/android/app/src/main/jni/emu_window/emu_window.h
new file mode 100644
index 000000000..b38087f73
--- /dev/null
+++ b/src/android/app/src/main/jni/emu_window/emu_window.h
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <memory>
+#include <span>
+
+#include "core/frontend/emu_window.h"
+#include "core/frontend/graphics_context.h"
+#include "input_common/main.h"
+
+struct ANativeWindow;
+
+class GraphicsContext_Android final : public Core::Frontend::GraphicsContext {
+public:
+ explicit GraphicsContext_Android(std::shared_ptr<Common::DynamicLibrary> driver_library)
+ : m_driver_library{driver_library} {}
+
+ ~GraphicsContext_Android() = default;
+
+ std::shared_ptr<Common::DynamicLibrary> GetDriverLibrary() override {
+ return m_driver_library;
+ }
+
+private:
+ std::shared_ptr<Common::DynamicLibrary> m_driver_library;
+};
+
+class EmuWindow_Android final : public Core::Frontend::EmuWindow {
+
+public:
+ EmuWindow_Android(InputCommon::InputSubsystem* input_subsystem, ANativeWindow* surface,
+ std::shared_ptr<Common::DynamicLibrary> driver_library);
+
+ ~EmuWindow_Android();
+
+ void OnSurfaceChanged(ANativeWindow* surface);
+ void OnTouchPressed(int id, float x, float y);
+ void OnTouchMoved(int id, float x, float y);
+ void OnTouchReleased(int id);
+ void OnGamepadButtonEvent(int player_index, int button_id, bool pressed);
+ void OnGamepadJoystickEvent(int player_index, int stick_id, float x, float y);
+ void OnGamepadMotionEvent(int player_index, u64 delta_timestamp, float gyro_x, float gyro_y,
+ float gyro_z, float accel_x, float accel_y, float accel_z);
+ void OnReadNfcTag(std::span<u8> data);
+ void OnRemoveNfcTag();
+ void OnFrameDisplayed() override {}
+
+ std::unique_ptr<Core::Frontend::GraphicsContext> CreateSharedContext() const override {
+ return {std::make_unique<GraphicsContext_Android>(m_driver_library)};
+ }
+ bool IsShown() const override {
+ return true;
+ };
+
+private:
+ InputCommon::InputSubsystem* m_input_subsystem{};
+
+ float m_window_width{};
+ float m_window_height{};
+
+ std::shared_ptr<Common::DynamicLibrary> m_driver_library;
+};
diff --git a/src/android/app/src/main/jni/id_cache.cpp b/src/android/app/src/main/jni/id_cache.cpp
new file mode 100644
index 000000000..9cbbf23a3
--- /dev/null
+++ b/src/android/app/src/main/jni/id_cache.cpp
@@ -0,0 +1,116 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <jni.h>
+
+#include "common/assert.h"
+#include "common/fs/fs_android.h"
+#include "jni/applets/software_keyboard.h"
+#include "jni/id_cache.h"
+#include "video_core/rasterizer_interface.h"
+
+static JavaVM* s_java_vm;
+static jclass s_native_library_class;
+static jclass s_disk_cache_progress_class;
+static jclass s_load_callback_stage_class;
+static jmethodID s_exit_emulation_activity;
+static jmethodID s_disk_cache_load_progress;
+
+static constexpr jint JNI_VERSION = JNI_VERSION_1_6;
+
+namespace IDCache {
+
+JNIEnv* GetEnvForThread() {
+ thread_local static struct OwnedEnv {
+ OwnedEnv() {
+ status = s_java_vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6);
+ if (status == JNI_EDETACHED)
+ s_java_vm->AttachCurrentThread(&env, nullptr);
+ }
+
+ ~OwnedEnv() {
+ if (status == JNI_EDETACHED)
+ s_java_vm->DetachCurrentThread();
+ }
+
+ int status;
+ JNIEnv* env = nullptr;
+ } owned;
+ return owned.env;
+}
+
+jclass GetNativeLibraryClass() {
+ return s_native_library_class;
+}
+
+jclass GetDiskCacheProgressClass() {
+ return s_disk_cache_progress_class;
+}
+
+jclass GetDiskCacheLoadCallbackStageClass() {
+ return s_load_callback_stage_class;
+}
+
+jmethodID GetExitEmulationActivity() {
+ return s_exit_emulation_activity;
+}
+
+jmethodID GetDiskCacheLoadProgress() {
+ return s_disk_cache_load_progress;
+}
+
+} // namespace IDCache
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+jint JNI_OnLoad(JavaVM* vm, void* reserved) {
+ s_java_vm = vm;
+
+ JNIEnv* env;
+ if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION) != JNI_OK)
+ return JNI_ERR;
+
+ // Initialize Java classes
+ const jclass native_library_class = env->FindClass("org/yuzu/yuzu_emu/NativeLibrary");
+ s_native_library_class = reinterpret_cast<jclass>(env->NewGlobalRef(native_library_class));
+ s_disk_cache_progress_class = reinterpret_cast<jclass>(env->NewGlobalRef(
+ env->FindClass("org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress")));
+ s_load_callback_stage_class = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass(
+ "org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress$LoadCallbackStage")));
+
+ // Initialize methods
+ s_exit_emulation_activity =
+ env->GetStaticMethodID(s_native_library_class, "exitEmulationActivity", "(I)V");
+ s_disk_cache_load_progress =
+ env->GetStaticMethodID(s_disk_cache_progress_class, "loadProgress", "(III)V");
+
+ // Initialize Android Storage
+ Common::FS::Android::RegisterCallbacks(env, s_native_library_class);
+
+ // Initialize applets
+ SoftwareKeyboard::InitJNI(env);
+
+ return JNI_VERSION;
+}
+
+void JNI_OnUnload(JavaVM* vm, void* reserved) {
+ JNIEnv* env;
+ if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION) != JNI_OK) {
+ return;
+ }
+
+ // UnInitialize Android Storage
+ Common::FS::Android::UnRegisterCallbacks();
+ env->DeleteGlobalRef(s_native_library_class);
+ env->DeleteGlobalRef(s_disk_cache_progress_class);
+ env->DeleteGlobalRef(s_load_callback_stage_class);
+
+ // UnInitialize applets
+ SoftwareKeyboard::CleanupJNI(env);
+}
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/android/app/src/main/jni/id_cache.h b/src/android/app/src/main/jni/id_cache.h
new file mode 100644
index 000000000..be535fe1e
--- /dev/null
+++ b/src/android/app/src/main/jni/id_cache.h
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#pragma once
+
+#include <jni.h>
+
+#include "video_core/rasterizer_interface.h"
+
+namespace IDCache {
+
+JNIEnv* GetEnvForThread();
+jclass GetNativeLibraryClass();
+jclass GetDiskCacheProgressClass();
+jclass GetDiskCacheLoadCallbackStageClass();
+jmethodID GetExitEmulationActivity();
+jmethodID GetDiskCacheLoadProgress();
+
+} // namespace IDCache
diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp
new file mode 100644
index 000000000..b87e04b3d
--- /dev/null
+++ b/src/android/app/src/main/jni/native.cpp
@@ -0,0 +1,774 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include <codecvt>
+#include <locale>
+#include <string>
+#include <string_view>
+#include <dlfcn.h>
+
+#ifdef ARCHITECTURE_arm64
+#include <adrenotools/driver.h>
+#endif
+
+#include <android/api-level.h>
+#include <android/native_window_jni.h>
+
+#include "common/detached_tasks.h"
+#include "common/dynamic_library.h"
+#include "common/fs/path_util.h"
+#include "common/logging/backend.h"
+#include "common/logging/log.h"
+#include "common/microprofile.h"
+#include "common/scm_rev.h"
+#include "common/scope_exit.h"
+#include "common/settings.h"
+#include "common/string_util.h"
+#include "core/core.h"
+#include "core/cpu_manager.h"
+#include "core/crypto/key_manager.h"
+#include "core/file_sys/registered_cache.h"
+#include "core/file_sys/vfs_real.h"
+#include "core/frontend/applets/cabinet.h"
+#include "core/frontend/applets/controller.h"
+#include "core/frontend/applets/error.h"
+#include "core/frontend/applets/general_frontend.h"
+#include "core/frontend/applets/mii_edit.h"
+#include "core/frontend/applets/profile_select.h"
+#include "core/frontend/applets/software_keyboard.h"
+#include "core/frontend/applets/web_browser.h"
+#include "core/hid/emulated_controller.h"
+#include "core/hid/hid_core.h"
+#include "core/hid/hid_types.h"
+#include "core/hle/service/acc/profile_manager.h"
+#include "core/hle/service/am/applet_ae.h"
+#include "core/hle/service/am/applet_oe.h"
+#include "core/hle/service/am/applets/applets.h"
+#include "core/hle/service/filesystem/filesystem.h"
+#include "core/loader/loader.h"
+#include "core/perf_stats.h"
+#include "jni/android_common/android_common.h"
+#include "jni/applets/software_keyboard.h"
+#include "jni/config.h"
+#include "jni/emu_window/emu_window.h"
+#include "jni/id_cache.h"
+#include "video_core/rasterizer_interface.h"
+#include "video_core/renderer_base.h"
+
+namespace {
+
+class EmulationSession final {
+public:
+ EmulationSession() {
+ m_vfs = std::make_shared<FileSys::RealVfsFilesystem>();
+ }
+
+ ~EmulationSession() = default;
+
+ static EmulationSession& GetInstance() {
+ return s_instance;
+ }
+
+ const Core::System& System() const {
+ return m_system;
+ }
+
+ Core::System& System() {
+ return m_system;
+ }
+
+ const EmuWindow_Android& Window() const {
+ return *m_window;
+ }
+
+ EmuWindow_Android& Window() {
+ return *m_window;
+ }
+
+ ANativeWindow* NativeWindow() const {
+ return m_native_window;
+ }
+
+ void SetNativeWindow(ANativeWindow* native_window) {
+ m_native_window = native_window;
+ }
+
+ u32 ScreenRotation() const {
+ return m_screen_rotation;
+ }
+
+ void SetScreenRotation(u32 screen_rotation) {
+ m_screen_rotation = screen_rotation;
+ }
+
+ void InitializeGpuDriver(const std::string& hook_lib_dir, const std::string& custom_driver_dir,
+ const std::string& custom_driver_name,
+ const std::string& file_redirect_dir) {
+#ifdef ARCHITECTURE_arm64
+ void* handle{};
+ const char* file_redirect_dir_{};
+ int featureFlags{};
+
+ // Enable driver file redirection when renderer debugging is enabled.
+ if (Settings::values.renderer_debug && file_redirect_dir.size()) {
+ featureFlags |= ADRENOTOOLS_DRIVER_FILE_REDIRECT;
+ file_redirect_dir_ = file_redirect_dir.c_str();
+ }
+
+ // Try to load a custom driver.
+ if (custom_driver_name.size()) {
+ handle = adrenotools_open_libvulkan(
+ RTLD_NOW, featureFlags | ADRENOTOOLS_DRIVER_CUSTOM, nullptr, hook_lib_dir.c_str(),
+ custom_driver_dir.c_str(), custom_driver_name.c_str(), file_redirect_dir_, nullptr);
+ }
+
+ // Try to load the system driver.
+ if (!handle) {
+ handle =
+ adrenotools_open_libvulkan(RTLD_NOW, featureFlags, nullptr, hook_lib_dir.c_str(),
+ nullptr, nullptr, file_redirect_dir_, nullptr);
+ }
+
+ m_vulkan_library = std::make_shared<Common::DynamicLibrary>(handle);
+#endif
+ }
+
+ bool IsRunning() const {
+ std::scoped_lock lock(m_mutex);
+ return m_is_running;
+ }
+
+ const Core::PerfStatsResults& PerfStats() const {
+ std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex);
+ return m_perf_stats;
+ }
+
+ void SurfaceChanged() {
+ if (!IsRunning()) {
+ return;
+ }
+ m_window->OnSurfaceChanged(m_native_window);
+ m_system.Renderer().NotifySurfaceChanged();
+ }
+
+ Core::SystemResultStatus InitializeEmulation(const std::string& filepath) {
+ std::scoped_lock lock(m_mutex);
+
+ // Loads the configuration.
+ Config{};
+
+ // Create the render window.
+ m_window = std::make_unique<EmuWindow_Android>(&m_input_subsystem, m_native_window,
+ m_vulkan_library);
+
+ // Initialize system.
+ auto android_keyboard = std::make_unique<SoftwareKeyboard::AndroidKeyboard>();
+ m_software_keyboard = android_keyboard.get();
+ m_system.SetShuttingDown(false);
+ m_system.ApplySettings();
+ m_system.HIDCore().ReloadInputDevices();
+ m_system.SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
+ m_system.SetFilesystem(std::make_shared<FileSys::RealVfsFilesystem>());
+ m_system.SetAppletFrontendSet({
+ nullptr, // Amiibo Settings
+ nullptr, // Controller Selector
+ nullptr, // Error Display
+ nullptr, // Mii Editor
+ nullptr, // Parental Controls
+ nullptr, // Photo Viewer
+ nullptr, // Profile Selector
+ std::move(android_keyboard), // Software Keyboard
+ nullptr, // Web Browser
+ });
+ m_system.GetFileSystemController().CreateFactories(*m_system.GetFilesystem());
+
+ // Initialize account manager
+ m_profile_manager = std::make_unique<Service::Account::ProfileManager>();
+
+ // Load the ROM.
+ m_load_result = m_system.Load(EmulationSession::GetInstance().Window(), filepath);
+ if (m_load_result != Core::SystemResultStatus::Success) {
+ return m_load_result;
+ }
+
+ // Complete initialization.
+ m_system.GPU().Start();
+ m_system.GetCpuManager().OnGpuReady();
+ m_system.RegisterExitCallback([&] { HaltEmulation(); });
+
+ return Core::SystemResultStatus::Success;
+ }
+
+ void ShutdownEmulation() {
+ std::scoped_lock lock(m_mutex);
+
+ m_is_running = false;
+
+ // Unload user input.
+ m_system.HIDCore().UnloadInputDevices();
+
+ // Shutdown the main emulated process
+ if (m_load_result == Core::SystemResultStatus::Success) {
+ m_system.DetachDebugger();
+ m_system.ShutdownMainProcess();
+ m_detached_tasks.WaitForAllTasks();
+ m_load_result = Core::SystemResultStatus::ErrorNotInitialized;
+ }
+
+ // Tear down the render window.
+ m_window.reset();
+ }
+
+ void PauseEmulation() {
+ std::scoped_lock lock(m_mutex);
+ m_system.Pause();
+ }
+
+ void UnPauseEmulation() {
+ std::scoped_lock lock(m_mutex);
+ m_system.Run();
+ }
+
+ void HaltEmulation() {
+ std::scoped_lock lock(m_mutex);
+ m_is_running = false;
+ m_cv.notify_one();
+ }
+
+ void RunEmulation() {
+ {
+ std::scoped_lock lock(m_mutex);
+ m_is_running = true;
+ }
+
+ // Load the disk shader cache.
+ if (Settings::values.use_disk_shader_cache.GetValue()) {
+ LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Prepare, 0, 0);
+ m_system.Renderer().ReadRasterizer()->LoadDiskResources(
+ m_system.GetApplicationProcessProgramID(), std::stop_token{},
+ LoadDiskCacheProgress);
+ LoadDiskCacheProgress(VideoCore::LoadCallbackStage::Complete, 0, 0);
+ }
+
+ void(m_system.Run());
+
+ if (m_system.DebuggerEnabled()) {
+ m_system.InitializeDebugger();
+ }
+
+ while (true) {
+ {
+ std::unique_lock lock(m_mutex);
+ if (m_cv.wait_for(lock, std::chrono::milliseconds(800),
+ [&]() { return !m_is_running; })) {
+ // Emulation halted.
+ break;
+ }
+ }
+ {
+ // Refresh performance stats.
+ std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex);
+ m_perf_stats = m_system.GetAndResetPerfStats();
+ }
+ }
+ }
+
+ std::string GetRomTitle(const std::string& path) {
+ return GetRomMetadata(path).title;
+ }
+
+ std::vector<u8> GetRomIcon(const std::string& path) {
+ return GetRomMetadata(path).icon;
+ }
+
+ void ResetRomMetadata() {
+ m_rom_metadata_cache.clear();
+ }
+
+ bool IsHandheldOnly() {
+ const auto npad_style_set = m_system.HIDCore().GetSupportedStyleTag();
+
+ if (npad_style_set.fullkey == 1) {
+ return false;
+ }
+
+ if (npad_style_set.handheld == 0) {
+ return false;
+ }
+
+ return !Settings::values.use_docked_mode.GetValue();
+ }
+
+ void SetDeviceType(int index, int type) {
+ auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index);
+ controller->SetNpadStyleIndex(static_cast<Core::HID::NpadStyleIndex>(type));
+ }
+
+ void OnGamepadConnectEvent(int index) {
+ auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index);
+
+ // Ensure that player1 is configured correctly and handheld disconnected
+ if (controller->GetNpadIdType() == Core::HID::NpadIdType::Player1) {
+ auto handheld =
+ m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Handheld);
+
+ if (controller->GetNpadStyleIndex() == Core::HID::NpadStyleIndex::Handheld) {
+ handheld->SetNpadStyleIndex(Core::HID::NpadStyleIndex::ProController);
+ controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::ProController);
+ handheld->Disconnect();
+ }
+ }
+
+ // Ensure that handheld is configured correctly and player 1 disconnected
+ if (controller->GetNpadIdType() == Core::HID::NpadIdType::Handheld) {
+ auto player1 = m_system.HIDCore().GetEmulatedController(Core::HID::NpadIdType::Player1);
+
+ if (controller->GetNpadStyleIndex() != Core::HID::NpadStyleIndex::Handheld) {
+ player1->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld);
+ controller->SetNpadStyleIndex(Core::HID::NpadStyleIndex::Handheld);
+ player1->Disconnect();
+ }
+ }
+
+ if (!controller->IsConnected()) {
+ controller->Connect();
+ }
+ }
+
+ void OnGamepadDisconnectEvent(int index) {
+ auto controller = m_system.HIDCore().GetEmulatedControllerByIndex(index);
+ controller->Disconnect();
+ }
+
+ SoftwareKeyboard::AndroidKeyboard* SoftwareKeyboard() {
+ return m_software_keyboard;
+ }
+
+private:
+ struct RomMetadata {
+ std::string title;
+ std::vector<u8> icon;
+ };
+
+ RomMetadata GetRomMetadata(const std::string& path) {
+ if (auto search = m_rom_metadata_cache.find(path); search != m_rom_metadata_cache.end()) {
+ return search->second;
+ }
+
+ return CacheRomMetadata(path);
+ }
+
+ RomMetadata CacheRomMetadata(const std::string& path) {
+ const auto file = Core::GetGameFileFromPath(m_vfs, path);
+ const auto loader = Loader::GetLoader(EmulationSession::GetInstance().System(), file, 0, 0);
+
+ RomMetadata entry;
+ loader->ReadTitle(entry.title);
+ loader->ReadIcon(entry.icon);
+
+ m_rom_metadata_cache[path] = entry;
+
+ return entry;
+ }
+
+private:
+ static void LoadDiskCacheProgress(VideoCore::LoadCallbackStage stage, int progress, int max) {
+ JNIEnv* env = IDCache::GetEnvForThread();
+ env->CallStaticVoidMethod(IDCache::GetDiskCacheProgressClass(),
+ IDCache::GetDiskCacheLoadProgress(), static_cast<jint>(stage),
+ static_cast<jint>(progress), static_cast<jint>(max));
+ }
+
+private:
+ static EmulationSession s_instance;
+
+ // Frontend management
+ std::unordered_map<std::string, RomMetadata> m_rom_metadata_cache;
+
+ // Window management
+ std::unique_ptr<EmuWindow_Android> m_window;
+ ANativeWindow* m_native_window{};
+ u32 m_screen_rotation{};
+
+ // Core emulation
+ Core::System m_system;
+ InputCommon::InputSubsystem m_input_subsystem;
+ Common::DetachedTasks m_detached_tasks;
+ Core::PerfStatsResults m_perf_stats{};
+ std::shared_ptr<FileSys::RealVfsFilesystem> m_vfs;
+ Core::SystemResultStatus m_load_result{Core::SystemResultStatus::ErrorNotInitialized};
+ bool m_is_running{};
+ SoftwareKeyboard::AndroidKeyboard* m_software_keyboard{};
+ std::unique_ptr<Service::Account::ProfileManager> m_profile_manager;
+
+ // GPU driver parameters
+ std::shared_ptr<Common::DynamicLibrary> m_vulkan_library;
+
+ // Synchronization
+ std::condition_variable_any m_cv;
+ mutable std::mutex m_perf_stats_mutex;
+ mutable std::mutex m_mutex;
+};
+
+/*static*/ EmulationSession EmulationSession::s_instance;
+
+} // Anonymous namespace
+
+u32 GetAndroidScreenRotation() {
+ return EmulationSession::GetInstance().ScreenRotation();
+}
+
+static Core::SystemResultStatus RunEmulation(const std::string& filepath) {
+ Common::Log::Initialize();
+ Common::Log::SetColorConsoleBackendEnabled(true);
+ Common::Log::Start();
+
+ MicroProfileOnThreadCreate("EmuThread");
+ SCOPE_EXIT({ MicroProfileShutdown(); });
+
+ LOG_INFO(Frontend, "starting");
+
+ if (filepath.empty()) {
+ LOG_CRITICAL(Frontend, "failed to load: filepath empty!");
+ return Core::SystemResultStatus::ErrorLoader;
+ }
+
+ SCOPE_EXIT({ EmulationSession::GetInstance().ShutdownEmulation(); });
+
+ const auto result = EmulationSession::GetInstance().InitializeEmulation(filepath);
+ if (result != Core::SystemResultStatus::Success) {
+ return result;
+ }
+
+ EmulationSession::GetInstance().RunEmulation();
+
+ return Core::SystemResultStatus::Success;
+}
+
+extern "C" {
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceChanged(JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jobject surf) {
+ EmulationSession::GetInstance().SetNativeWindow(ANativeWindow_fromSurface(env, surf));
+ EmulationSession::GetInstance().SurfaceChanged();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_surfaceDestroyed(JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ ANativeWindow_release(EmulationSession::GetInstance().NativeWindow());
+ EmulationSession::GetInstance().SetNativeWindow(nullptr);
+ EmulationSession::GetInstance().SurfaceChanged();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_notifyOrientationChange(JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jint layout_option,
+ jint rotation) {
+ return EmulationSession::GetInstance().SetScreenRotation(static_cast<u32>(rotation));
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_setAppDirectory(JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_directory) {
+ Common::FS::SetAppDirectory(GetJString(env, j_directory));
+}
+
+void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeGpuDriver(
+ JNIEnv* env, [[maybe_unused]] jclass clazz, jstring hook_lib_dir, jstring custom_driver_dir,
+ jstring custom_driver_name, jstring file_redirect_dir) {
+ EmulationSession::GetInstance().InitializeGpuDriver(
+ GetJString(env, hook_lib_dir), GetJString(env, custom_driver_dir),
+ GetJString(env, custom_driver_name), GetJString(env, file_redirect_dir));
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_reloadKeys(JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ Core::Crypto::KeyManager::Instance().ReloadKeys();
+ return static_cast<jboolean>(Core::Crypto::KeyManager::Instance().AreKeysLoaded());
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_unPauseEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ EmulationSession::GetInstance().UnPauseEmulation();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_pauseEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ EmulationSession::GetInstance().PauseEmulation();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_stopEmulation([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ EmulationSession::GetInstance().HaltEmulation();
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_resetRomMetadata([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ EmulationSession::GetInstance().ResetRomMetadata();
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isRunning([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ return static_cast<jboolean>(EmulationSession::GetInstance().IsRunning());
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_isHandheldOnly([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ return EmulationSession::GetInstance().IsHandheldOnly();
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_setDeviceType([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jint j_device, jint j_type) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().SetDeviceType(j_device, j_type);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadConnectEvent([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jint j_device) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().OnGamepadConnectEvent(j_device);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadDisconnectEvent(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint j_device) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().OnGamepadDisconnectEvent(j_device);
+ }
+ return static_cast<jboolean>(true);
+}
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadButtonEvent([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ [[maybe_unused]] jint j_device,
+ jint j_button, jint action) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ // Ensure gamepad is connected
+ EmulationSession::GetInstance().OnGamepadConnectEvent(j_device);
+ EmulationSession::GetInstance().Window().OnGamepadButtonEvent(j_device, j_button,
+ action != 0);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadJoystickEvent([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jint j_device, jint stick_id,
+ jfloat x, jfloat y) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnGamepadJoystickEvent(j_device, stick_id, x, y);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadMotionEvent(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jint j_device,
+ jlong delta_timestamp, jfloat gyro_x, jfloat gyro_y, jfloat gyro_z, jfloat accel_x,
+ jfloat accel_y, jfloat accel_z) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnGamepadMotionEvent(
+ j_device, delta_timestamp, gyro_x, gyro_y, gyro_z, accel_x, accel_y, accel_z);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onReadNfcTag([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jbyteArray j_data) {
+ jboolean isCopy{false};
+ std::span<u8> data(reinterpret_cast<u8*>(env->GetByteArrayElements(j_data, &isCopy)),
+ static_cast<size_t>(env->GetArrayLength(j_data)));
+
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnReadNfcTag(data);
+ }
+ return static_cast<jboolean>(true);
+}
+
+jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_onRemoveNfcTag([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnRemoveNfcTag();
+ }
+ return static_cast<jboolean>(true);
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchPressed([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz, jint id,
+ jfloat x, jfloat y) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnTouchPressed(id, x, y);
+ }
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz, jint id,
+ jfloat x, jfloat y) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnTouchMoved(id, x, y);
+ }
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchReleased([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz, jint id) {
+ if (EmulationSession::GetInstance().IsRunning()) {
+ EmulationSession::GetInstance().Window().OnTouchReleased(id);
+ }
+}
+
+jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getIcon([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ [[maybe_unused]] jstring j_filename) {
+ auto icon_data = EmulationSession::GetInstance().GetRomIcon(GetJString(env, j_filename));
+ jbyteArray icon = env->NewByteArray(static_cast<jsize>(icon_data.size()));
+ env->SetByteArrayRegion(icon, 0, env->GetArrayLength(icon),
+ reinterpret_cast<jbyte*>(icon_data.data()));
+ return icon;
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getTitle([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ [[maybe_unused]] jstring j_filename) {
+ auto title = EmulationSession::GetInstance().GetRomTitle(GetJString(env, j_filename));
+ return env->NewStringUTF(title.c_str());
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getDescription([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_filename) {
+ return j_filename;
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getGameId([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_filename) {
+ return j_filename;
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getRegions([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ [[maybe_unused]] jstring j_filename) {
+ return env->NewStringUTF("");
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getCompany([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ [[maybe_unused]] jstring j_filename) {
+ return env->NewStringUTF("");
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmulation
+ [[maybe_unused]] (JNIEnv* env, [[maybe_unused]] jclass clazz) {
+ // Create the default config.ini.
+ Config{};
+ // Initialize the emulated system.
+ EmulationSession::GetInstance().System().Initialize();
+}
+
+jint Java_org_yuzu_yuzu_1emu_NativeLibrary_defaultCPUCore([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ return {};
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2Ljava_lang_String_2Z(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, [[maybe_unused]] jstring j_file,
+ [[maybe_unused]] jstring j_savestate, [[maybe_unused]] jboolean j_delete_savestate) {}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_reloadSettings([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ Config{};
+}
+
+jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getUserSetting([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_game_id, jstring j_section,
+ jstring j_key) {
+ std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
+ std::string_view section = env->GetStringUTFChars(j_section, 0);
+ std::string_view key = env->GetStringUTFChars(j_key, 0);
+
+ env->ReleaseStringUTFChars(j_game_id, game_id.data());
+ env->ReleaseStringUTFChars(j_section, section.data());
+ env->ReleaseStringUTFChars(j_key, key.data());
+
+ return env->NewStringUTF("");
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_setUserSetting([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_game_id, jstring j_section,
+ jstring j_key, jstring j_value) {
+ std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
+ std::string_view section = env->GetStringUTFChars(j_section, 0);
+ std::string_view key = env->GetStringUTFChars(j_key, 0);
+ std::string_view value = env->GetStringUTFChars(j_value, 0);
+
+ env->ReleaseStringUTFChars(j_game_id, game_id.data());
+ env->ReleaseStringUTFChars(j_section, section.data());
+ env->ReleaseStringUTFChars(j_key, key.data());
+ env->ReleaseStringUTFChars(j_value, value.data());
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_initGameIni([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_game_id) {
+ std::string_view game_id = env->GetStringUTFChars(j_game_id, 0);
+
+ env->ReleaseStringUTFChars(j_game_id, game_id.data());
+}
+
+jdoubleArray Java_org_yuzu_yuzu_1emu_NativeLibrary_getPerfStats([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ jdoubleArray j_stats = env->NewDoubleArray(4);
+
+ if (EmulationSession::GetInstance().IsRunning()) {
+ const auto results = EmulationSession::GetInstance().PerfStats();
+
+ // Converting the structure into an array makes it easier to pass it to the frontend
+ double stats[4] = {results.system_fps, results.average_game_fps, results.frametime,
+ results.emulation_speed};
+
+ env->SetDoubleArrayRegion(j_stats, 0, 4, stats);
+ }
+
+ return j_stats;
+}
+
+void Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_setSysDirectory(
+ [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jclass clazz, jstring j_path) {}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_run__Ljava_lang_String_2([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz,
+ jstring j_path) {
+ const std::string path = GetJString(env, j_path);
+
+ const Core::SystemResultStatus result{RunEmulation(path)};
+ if (result != Core::SystemResultStatus::Success) {
+ env->CallStaticVoidMethod(IDCache::GetNativeLibraryClass(),
+ IDCache::GetExitEmulationActivity(), static_cast<int>(result));
+ }
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_logDeviceInfo([[maybe_unused]] JNIEnv* env,
+ [[maybe_unused]] jclass clazz) {
+ LOG_INFO(Frontend, "yuzu Version: {}-{}", Common::g_scm_branch, Common::g_scm_desc);
+ LOG_INFO(Frontend, "Host OS: Android API level {}", android_get_device_api_level());
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardText(JNIEnv* env, jclass clazz,
+ jstring j_text) {
+ const std::u16string input = Common::UTF8ToUTF16(GetJString(env, j_text));
+ EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardText(input);
+}
+
+void Java_org_yuzu_yuzu_1emu_NativeLibrary_submitInlineKeyboardInput(JNIEnv* env, jclass clazz,
+ jint j_key_code) {
+ EmulationSession::GetInstance().SoftwareKeyboard()->SubmitInlineKeyboardInput(j_key_code);
+}
+
+} // extern "C"
diff --git a/src/android/app/src/main/jni/native.h b/src/android/app/src/main/jni/native.h
new file mode 100644
index 000000000..24dcbbcb8
--- /dev/null
+++ b/src/android/app/src/main/jni/native.h
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include <jni.h>
+
+// Function calls from the Java side
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ResetRomMetadata(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_isHandheldOnly(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_setDeviceType(JNIEnv* env,
+ jclass clazz,
+ jstring j_device,
+ jstring j_type);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadConnectEvent(
+ JNIEnv* env, jclass clazz, jstring j_device);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadDisconnectEvent(
+ JNIEnv* env, jclass clazz, jstring j_device);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadEvent(
+ JNIEnv* env, jclass clazz, jstring j_device, jint j_button, jint action);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadMoveEvent(
+ JNIEnv* env, jclass clazz, jstring j_device, jint axis, jfloat x, jfloat y);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onGamePadAxisEvent(
+ JNIEnv* env, jclass clazz, jstring j_device, jint axis_id, jfloat axis_val);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onReadNfcTag(JNIEnv* env,
+ jclass clazz,
+ jbyteArray j_data);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onRemoveNfcTag(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchEvent(JNIEnv* env,
+ jclass clazz,
+ jfloat x, jfloat y,
+ jboolean pressed);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz,
+ jfloat x, jfloat y);
+
+JNIEXPORT jbyteArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env,
+ jclass clazz,
+ jstring j_file);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, jclass clazz,
+ jstring j_filename);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription(JNIEnv* env,
+ jclass clazz,
+ jstring j_filename);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGameId(JNIEnv* env, jclass clazz,
+ jstring j_filename);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetRegions(JNIEnv* env,
+ jclass clazz,
+ jstring j_filename);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetCompany(JNIEnv* env,
+ jclass clazz,
+ jstring j_filename);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetGitRevision(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetAppDirectory(JNIEnv* env,
+ jclass clazz,
+ jstring j_directory);
+
+JNIEXPORT void JNICALL
+Java_org_yuzu_yuzu_1emu_NativeLibrary_Java_org_yuzu_yuzu_1emu_NativeLibrary_InitializeGpuDriver(
+ JNIEnv* env, jclass clazz, jstring hook_lib_dir, jstring custom_driver_dir,
+ jstring custom_driver_name, jstring file_redirect_dir);
+
+JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadKeys(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_utils_DirectoryInitialization_SetSysDirectory(
+ JNIEnv* env, jclass clazz, jstring path_);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetSysDirectory(JNIEnv* env,
+ jclass clazz,
+ jstring path);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitializeEmulation(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT jint JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_DefaultCPUCore(JNIEnv* env,
+ jclass clazz);
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetProfiling(JNIEnv* env, jclass clazz,
+ jboolean enable);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_WriteProfileResults(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_NotifyOrientationChange(
+ JNIEnv* env, jclass clazz, jint layout_option, jint rotation);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_Run__Ljava_lang_String_2(
+ JNIEnv* env, jclass clazz, jstring j_path);
+
+JNIEXPORT void JNICALL
+Java_org_yuzu_yuzu_1emu_NativeLibrary_Run__Ljava_lang_String_2Ljava_lang_String_2Z(
+ JNIEnv* env, jclass clazz, jstring j_file, jstring j_savestate, jboolean j_delete_savestate);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceChanged(JNIEnv* env,
+ jclass clazz,
+ jobject surf);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SurfaceDestroyed(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_InitGameIni(JNIEnv* env, jclass clazz,
+ jstring j_game_id);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadSettings(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SetUserSetting(
+ JNIEnv* env, jclass clazz, jstring j_game_id, jstring j_section, jstring j_key,
+ jstring j_value);
+
+JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetUserSetting(
+ JNIEnv* env, jclass clazz, jstring game_id, jstring section, jstring key);
+
+JNIEXPORT jdoubleArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetPerfStats(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_LogDeviceInfo(JNIEnv* env,
+ jclass clazz);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SubmitInlineKeyboardText(
+ JNIEnv* env, jclass clazz, jstring j_text);
+
+JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_SubmitInlineKeyboardInput(
+ JNIEnv* env, jclass clazz, jint j_key_code);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml b/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml
new file mode 100644
index 000000000..9f49c133a
--- /dev/null
+++ b/src/android/app/src/main/res/anim-ldrtl/anim_pop_settings_fragment_out.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <alpha
+ android:duration="125"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromAlpha="1"
+ android:toAlpha="0" />
+
+ <translate
+ android:duration="125"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromXDelta="0"
+ android:toXDelta="-75" />
+
+</set>
diff --git a/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml b/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml
new file mode 100644
index 000000000..82fd719db
--- /dev/null
+++ b/src/android/app/src/main/res/anim-ldrtl/anim_settings_fragment_in.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <alpha
+ android:duration="@android:integer/config_shortAnimTime"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1" />
+
+ <translate
+ android:duration="@android:integer/config_shortAnimTime"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromXDelta="-200"
+ android:toXDelta="0" />
+
+</set>
diff --git a/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml b/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml
new file mode 100644
index 000000000..5892128f1
--- /dev/null
+++ b/src/android/app/src/main/res/anim/anim_pop_settings_fragment_out.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <alpha
+ android:duration="125"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromAlpha="1"
+ android:toAlpha="0" />
+
+ <translate
+ android:duration="125"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromXDelta="0"
+ android:toXDelta="75" />
+
+</set>
diff --git a/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml b/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml
new file mode 100644
index 000000000..98e0cf8bd
--- /dev/null
+++ b/src/android/app/src/main/res/anim/anim_settings_fragment_in.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <alpha
+ android:duration="@android:integer/config_shortAnimTime"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromAlpha="0"
+ android:toAlpha="1" />
+
+ <translate
+ android:duration="@android:integer/config_shortAnimTime"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromXDelta="200"
+ android:toXDelta="0" />
+
+</set>
diff --git a/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml b/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml
new file mode 100644
index 000000000..77a40a4d1
--- /dev/null
+++ b/src/android/app/src/main/res/anim/anim_settings_fragment_out.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <alpha
+ android:duration="@android:integer/config_shortAnimTime"
+ android:interpolator="@android:anim/decelerate_interpolator"
+ android:fromAlpha="1"
+ android:toAlpha="0" />
+
+</set>
diff --git a/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml b/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml
new file mode 100644
index 000000000..4612aee13
--- /dev/null
+++ b/src/android/app/src/main/res/animator/menu_slide_in_from_start.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <objectAnimator
+ android:propertyName="translationX"
+ android:valueType="floatType"
+ android:valueFrom="-1280dp"
+ android:valueTo="0"
+ android:interpolator="@android:interpolator/decelerate_quad"
+ android:duration="300"/>
+
+ <objectAnimator
+ android:propertyName="alpha"
+ android:valueType="floatType"
+ android:valueFrom="0"
+ android:valueTo="1"
+ android:interpolator="@android:interpolator/accelerate_quad"
+ android:duration="300"/>
+
+</set> \ No newline at end of file
diff --git a/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml b/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml
new file mode 100644
index 000000000..c00478946
--- /dev/null
+++ b/src/android/app/src/main/res/animator/menu_slide_out_to_start.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<set xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <!-- This animation is used ONLY when a submenu is replaced. -->
+ <objectAnimator
+ android:propertyName="translationX"
+ android:valueType="floatType"
+ android:valueFrom="0"
+ android:valueTo="-1280dp"
+ android:interpolator="@android:interpolator/decelerate_quad"
+ android:duration="200"/>
+
+ <objectAnimator
+ android:propertyName="alpha"
+ android:valueType="floatType"
+ android:valueFrom="1"
+ android:valueTo="0"
+ android:interpolator="@android:interpolator/decelerate_quad"
+ android:duration="200"/>
+
+</set> \ No newline at end of file
diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png
new file mode 100644
index 000000000..66ebfa85c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png
Binary files differ
diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png
new file mode 100644
index 000000000..71068f452
--- /dev/null
+++ b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png
Binary files differ
diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png
new file mode 100644
index 000000000..d73fad15b
--- /dev/null
+++ b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png
Binary files differ
diff --git a/src/android/app/src/main/res/drawable/default_icon.jpg b/src/android/app/src/main/res/drawable/default_icon.jpg
new file mode 100644
index 000000000..859caf4af
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/default_icon.jpg
Binary files differ
diff --git a/src/android/app/src/main/res/drawable/dpad_standard.xml b/src/android/app/src/main/res/drawable/dpad_standard.xml
new file mode 100644
index 000000000..28aba657e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/dpad_standard.xml
@@ -0,0 +1,24 @@
+<vector android:alpha="0.6" android:height="221.78dp"
+ android:viewportHeight="221.78" android:viewportWidth="221.78"
+ android:width="221.78dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.75"
+ android:pathData="M221.78,87.07v47.64a11.53,11.53 0,0 1,-11.5 11.5H151.62a5.42,5.42 0,0 0,-5.41 5.41v58.66a11.53,11.53 0,0 1,-11.5 11.5H87.07a11.53,11.53 0,0 1,-11.5 -11.5V151.61a5.41,5.41 0,0 0,-5.41 -5.41H11.5A11.53,11.53 0,0 1,0 134.7V87.05a11.53,11.53 0,0 1,11.5 -11.5H70.16a5.41,5.41 0,0 0,5.41 -5.41V11.5A11.53,11.53 0,0 1,87.07 0h47.64a11.53,11.53 0,0 1,11.5 11.5V70.16a5.41,5.41 0,0 0,5.41 5.41h58.66A11.53,11.53 0,0 1,221.78 87.07Z" android:strokeAlpha="0.75">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="110.89" android:centerY="110.89"
+ android:gradientRadius="110.89" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillColor="#FF000000" android:pathData="M195.47,110.32l-16.26,-9.38a0.65,0.65 0,0 0,-1 0.56v18.78a0.66,0.66 0,0 0,1 0.57l16.26,-9.39A0.66,0.66 0,0 0,195.47 110.32Z"/>
+ <path android:fillColor="#FF000000" android:pathData="M26.31,110.32l16.26,-9.38a0.65,0.65 0,0 1,1 0.56v18.78a0.66,0.66 0,0 1,-1 0.57l-16.26,-9.39A0.66,0.66 0,0 1,26.31 110.32Z"/>
+ <path android:fillColor="#FF000000" android:pathData="M110.32,26.31l-9.38,16.26a0.65,0.65 0,0 0,0.56 1h18.78a0.66,0.66 0,0 0,0.57 -1l-9.39,-16.26A0.66,0.66 0,0 0,110.32 26.31Z"/>
+ <path android:fillColor="#FF000000" android:pathData="M110.32,195.47l-9.38,-16.26a0.65,0.65 0,0 1,0.56 -1h18.78a0.66,0.66 0,0 1,0.57 1l-9.39,16.26A0.66,0.66 0,0 1,110.32 195.47Z"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml b/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml
new file mode 100644
index 000000000..5eeb51dbe
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/dpad_standard_cardinal_depressed.xml
@@ -0,0 +1,24 @@
+<vector android:alpha="0.6" android:height="221.78dp"
+ android:viewportHeight="221.78" android:viewportWidth="221.78"
+ android:width="221.78dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5"
+ android:pathData="M221.78,87.07v47.64a11.53,11.53 0,0 1,-11.5 11.5H151.62a5.42,5.42 0,0 0,-5.41 5.41v58.66a11.53,11.53 0,0 1,-11.5 11.5H87.07a11.53,11.53 0,0 1,-11.5 -11.5V151.61a5.41,5.41 0,0 0,-5.41 -5.41H11.5A11.53,11.53 0,0 1,0 134.7V87.05a11.53,11.53 0,0 1,11.5 -11.5H70.16a5.41,5.41 0,0 0,5.41 -5.41V11.5A11.53,11.53 0,0 1,87.07 0h47.64a11.53,11.53 0,0 1,11.5 11.5V70.16a5.41,5.41 0,0 0,5.41 5.41h58.66A11.53,11.53 0,0 1,221.78 87.07Z" android:strokeAlpha="0.5">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="110.89" android:endY="-38.27"
+ android:startX="110.89" android:startY="183.51" android:type="linear">
+ <item android:color="#7F000000" android:offset="0"/>
+ <item android:color="#BA000000" android:offset="0.43"/>
+ <item android:color="#FF000000" android:offset="0.5"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.1" android:fillColor="#fff"
+ android:pathData="M195.47,110.32l-16.26,-9.38a0.65,0.65 0,0 0,-1 0.56v18.78a0.66,0.66 0,0 0,1 0.57l16.26,-9.39A0.66,0.66 0,0 0,195.47 110.32Z" android:strokeAlpha="0.1"/>
+ <path android:fillAlpha="0.1" android:fillColor="#fff"
+ android:pathData="M26.31,110.32l16.26,-9.38a0.65,0.65 0,0 1,1 0.56v18.78a0.66,0.66 0,0 1,-1 0.57l-16.26,-9.39A0.66,0.66 0,0 1,26.31 110.32Z" android:strokeAlpha="0.1"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M110.32,26.31l-9.38,16.26a0.65,0.65 0,0 0,0.56 1h18.78a0.66,0.66 0,0 0,0.57 -1l-9.39,-16.26A0.66,0.66 0,0 0,110.32 26.31Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.1" android:fillColor="#fff"
+ android:pathData="M110.32,195.47l-9.38,-16.26a0.65,0.65 0,0 1,0.56 -1h18.78a0.66,0.66 0,0 1,0.57 1l-9.39,16.26A0.66,0.66 0,0 1,110.32 195.47Z" android:strokeAlpha="0.1"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml b/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml
new file mode 100644
index 000000000..520fd447c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/dpad_standard_diagonal_depressed.xml
@@ -0,0 +1,24 @@
+<vector android:alpha="0.6" android:height="221.78dp"
+ android:viewportHeight="221.78" android:viewportWidth="221.78"
+ android:width="221.78dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5"
+ android:pathData="M221.78,87.07v47.64a11.53,11.53 0,0 1,-11.5 11.5H151.62a5.42,5.42 0,0 0,-5.41 5.41v58.66a11.53,11.53 0,0 1,-11.5 11.5H87.07a11.53,11.53 0,0 1,-11.5 -11.5V151.61a5.41,5.41 0,0 0,-5.41 -5.41H11.5A11.53,11.53 0,0 1,0 134.7V87.05a11.53,11.53 0,0 1,11.5 -11.5H70.16a5.41,5.41 0,0 0,5.41 -5.41V11.5A11.53,11.53 0,0 1,87.07 0h47.64a11.53,11.53 0,0 1,11.5 11.5V70.16a5.41,5.41 0,0 0,5.41 5.41h58.66A11.53,11.53 0,0 1,221.78 87.07Z" android:strokeAlpha="0.5">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="31.24" android:endY="31.24"
+ android:startX="188.07" android:startY="188.07" android:type="linear">
+ <item android:color="#7F000000" android:offset="0"/>
+ <item android:color="#BA000000" android:offset="0.43"/>
+ <item android:color="#FF000000" android:offset="0.5"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.1" android:fillColor="#fff"
+ android:pathData="M195.47,110.32l-16.26,-9.38a0.65,0.65 0,0 0,-1 0.56v18.78a0.66,0.66 0,0 0,1 0.57l16.26,-9.39A0.66,0.66 0,0 0,195.47 110.32Z" android:strokeAlpha="0.1"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M26.31,110.32l16.26,-9.38a0.65,0.65 0,0 1,1 0.56v18.78a0.66,0.66 0,0 1,-1 0.57l-16.26,-9.39A0.66,0.66 0,0 1,26.31 110.32Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M110.32,26.31l-9.38,16.26a0.65,0.65 0,0 0,0.56 1h18.78a0.66,0.66 0,0 0,0.57 -1l-9.39,-16.26A0.66,0.66 0,0 0,110.32 26.31Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.1" android:fillColor="#fff"
+ android:pathData="M110.32,195.47l-9.38,-16.26a0.65,0.65 0,0 1,0.56 -1h18.78a0.66,0.66 0,0 1,0.57 1l-9.39,16.26A0.66,0.66 0,0 1,110.32 195.47Z" android:strokeAlpha="0.1"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_a.xml b/src/android/app/src/main/res/drawable/facebutton_a.xml
new file mode 100644
index 000000000..668652edb
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_a.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="51.8" android:centerY="51.8"
+ android:gradientRadius="51.8" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.6" android:fillColor="#FF000000"
+ android:pathData="M49.88,34.36h4.29L69.1,69.25L63.58,69.25l-3.5,-8.63L43.48,60.62L40,69.25L34.51,69.25ZM58.36,56.48 L51.85,40.48h-0.1l-6.6,16Z" android:strokeAlpha="0.6"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml
new file mode 100644
index 000000000..4fbe06962
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_a_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M49.88,34.36h4.29L69.1,69.25L63.58,69.25l-3.5,-8.63L43.48,60.62L40,69.25L34.51,69.25ZM58.36,56.48 L51.85,40.48h-0.1l-6.6,16Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_b.xml b/src/android/app/src/main/res/drawable/facebutton_b.xml
new file mode 100644
index 000000000..8912219ca
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_b.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="51.8" android:centerY="51.8"
+ android:gradientRadius="51.8" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.6" android:fillColor="#FF000000"
+ android:pathData="M41,35.67L53.15,35.67a15.78,15.78 0,0 1,4.22 0.54,10.07 10.07,0 0,1 3.36,1.6A7.49,7.49 0,0 1,63 40.53a8.73,8.73 0,0 1,0.81 3.88,7.13 7.13,0 0,1 -1.67,4.91 9.75,9.75 0,0 1,-4.35 2.79v0.1a7.4,7.4 0,0 1,3 0.82,8.1 8.1,0 0,1 2.4,1.87 9.14,9.14 0,0 1,2.2 6,8.73 8.73,0 0,1 -1,4.17 8.86,8.86 0,0 1,-2.64 3A12.39,12.39 0,0 1,57.79 70a17.12,17.12 0,0 1,-4.79 0.64L41,70.64ZM45.74,50.19h6.47a10.75,10.75 0,0 0,2.52 -0.28A5.56,5.56 0,0 0,56.8 49a4.73,4.73 0,0 0,1.41 -1.63A5.22,5.22 0,0 0,58.73 45a5,5 0,0 0,-5.53 -5.13L45.74,39.87ZM45.74,66.48h7a14.17,14.17 0,0 0,2.4 -0.22,7.23 7.23,0 0,0 2.44,-0.89 6,6 0,0 0,1.93 -1.8,5.15 5.15,0 0,0 0.79,-3 5.52,5.52 0,0 0,-2 -4.67,8.75 8.75,0 0,0 -5.48,-1.56h-7Z" android:strokeAlpha="0.6"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml
new file mode 100644
index 000000000..012abeaf1
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_b_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M41,35.67L53.15,35.67a15.78,15.78 0,0 1,4.22 0.54,10.07 10.07,0 0,1 3.36,1.6A7.49,7.49 0,0 1,63 40.53a8.73,8.73 0,0 1,0.81 3.88,7.13 7.13,0 0,1 -1.67,4.91 9.75,9.75 0,0 1,-4.35 2.79v0.1a7.4,7.4 0,0 1,3 0.82,8.1 8.1,0 0,1 2.4,1.87 9.14,9.14 0,0 1,2.2 6,8.73 8.73,0 0,1 -1,4.17 8.86,8.86 0,0 1,-2.64 3A12.39,12.39 0,0 1,57.79 70a17.12,17.12 0,0 1,-4.79 0.64L41,70.64ZM45.74,50.19h6.47a10.75,10.75 0,0 0,2.52 -0.28A5.56,5.56 0,0 0,56.8 49a4.73,4.73 0,0 0,1.41 -1.63A5.22,5.22 0,0 0,58.73 45a5,5 0,0 0,-5.53 -5.13L45.74,39.87ZM45.74,66.48h7a14.17,14.17 0,0 0,2.4 -0.22,7.23 7.23,0 0,0 2.44,-0.89 6,6 0,0 0,1.93 -1.8,5.15 5.15,0 0,0 0.79,-3 5.52,5.52 0,0 0,-2 -4.67,8.75 8.75,0 0,0 -5.48,-1.56h-7Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_home.xml b/src/android/app/src/main/res/drawable/facebutton_home.xml
new file mode 100644
index 000000000..03596ec2e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_home.xml
@@ -0,0 +1,21 @@
+<vector android:alpha="0.6" android:height="70.55dp"
+ android:viewportHeight="70.55" android:viewportWidth="70.55"
+ android:width="70.55dp" xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.75"
+ android:pathData="M35.27,35.27m-35.27,0a35.27,35.27 0,1 1,70.54 0a35.27,35.27 0,1 1,-70.54 0" android:strokeAlpha="0.75">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="35.27" android:centerY="35.27"
+ android:gradientRadius="35.27" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M55.19,32.72 L36.06,15.21a1.14,1.14 0,0 0,-1.57 0L15.36,32.72a1.13,1.13 0,0 0,0.79 1.94H19.4a0.72,0.72 0,0 1,0.72 0.72V51.49a1.13,1.13 0,0 0,1.12 1.13H49.31a1.13,1.13 0,0 0,1.12 -1.13V35.38a0.72,0.72 0,0 1,0.72 -0.72H54.4A1.13,1.13 0,0 0,55.19 32.72ZM41.45,43.86a0.9,0.9 0,0 1,-0.9 0.9H30a0.9,0.9 0,0 1,-0.9 -0.9V35.55a0.89,0.89 0,0 1,0.9 -0.89H40.55a0.89,0.89 0,0 1,0.9 0.89Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml
new file mode 100644
index 000000000..cde7c6a9e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_home_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="70.55dp"
+ android:viewportHeight="70.55" android:viewportWidth="70.55"
+ android:width="70.55dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M35.27,35.27m-35.27,0a35.27,35.27 0,1 1,70.54 0a35.27,35.27 0,1 1,-70.54 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M55.19,32.72 L36.06,15.21a1.14,1.14 0,0 0,-1.57 0L15.36,32.72a1.13,1.13 0,0 0,0.79 1.94H19.4a0.72,0.72 0,0 1,0.72 0.72V51.49a1.13,1.13 0,0 0,1.12 1.13H49.31a1.13,1.13 0,0 0,1.12 -1.13V35.38a0.72,0.72 0,0 1,0.72 -0.72H54.4A1.13,1.13 0,0 0,55.19 32.72ZM41.45,43.86a0.9,0.9 0,0 1,-0.9 0.9H30a0.9,0.9 0,0 1,-0.9 -0.9V35.55a0.89,0.89 0,0 1,0.9 -0.89H40.55a0.89,0.89 0,0 1,0.9 0.89Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_minus.xml b/src/android/app/src/main/res/drawable/facebutton_minus.xml
new file mode 100644
index 000000000..4296b4fcc
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_minus.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="69.95dp"
+ android:viewportHeight="69.95" android:viewportWidth="69.95"
+ android:width="69.95dp" xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.75"
+ android:pathData="M34.97,34.97m-34.97,0a34.97,34.97 0,1 1,69.94 0a34.97,34.97 0,1 1,-69.94 0" android:strokeAlpha="0.75">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="34.97" android:centerY="34.97"
+ android:gradientRadius="34.97" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M52,38.28H17.91a0.52,0.52 0,0 1,-0.52 -0.52V32.19a0.52,0.52 0,0 1,0.52 -0.52H52a0.52,0.52 0,0 1,0.52 0.52v5.57A0.52,0.52 0,0 1,52 38.28Z"
+ android:strokeAlpha="0.75" android:strokeColor="#000" android:strokeWidth="2.5"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml
new file mode 100644
index 000000000..628027841
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_minus_depressed.xml
@@ -0,0 +1,9 @@
+<vector android:alpha="0.6" android:height="69.95dp"
+ android:viewportHeight="69.95" android:viewportWidth="69.95"
+ android:width="69.95dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M34.97,34.97m-34.97,0a34.97,34.97 0,1 1,69.94 0a34.97,34.97 0,1 1,-69.94 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M52,38.28H17.91a0.52,0.52 0,0 1,-0.52 -0.52V32.19a0.52,0.52 0,0 1,0.52 -0.52H52a0.52,0.52 0,0 1,0.52 0.52v5.57A0.52,0.52 0,0 1,52 38.28Z"
+ android:strokeAlpha="0.75" android:strokeColor="#fff" android:strokeWidth="2.5"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_plus.xml b/src/android/app/src/main/res/drawable/facebutton_plus.xml
new file mode 100644
index 000000000..43ae14365
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_plus.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="69.95dp"
+ android:viewportHeight="69.95" android:viewportWidth="69.95"
+ android:width="69.95dp" xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.75"
+ android:pathData="M34.97,34.97m-34.97,0a34.97,34.97 0,1 1,69.94 0a34.97,34.97 0,1 1,-69.94 0" android:strokeAlpha="0.75">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="34.97" android:centerY="34.97"
+ android:gradientRadius="34.97" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M13.94,31.9H31.16a0.65,0.65 0,0 0,0.65 -0.64V14.59a0.65,0.65 0,0 1,0.64 -0.65h5a0.65,0.65 0,0 1,0.65 0.65V31.26a0.65,0.65 0,0 0,0.65 0.64H56a0.65,0.65 0,0 1,0.65 0.65V37.4a0.65,0.65 0,0 1,-0.65 0.65H38.79a0.65,0.65 0,0 0,-0.65 0.64V55.36a0.65,0.65 0,0 1,-0.65 0.64h-5a0.64,0.64 0,0 1,-0.64 -0.64V38.69a0.65,0.65 0,0 0,-0.65 -0.64H13.94a0.65,0.65 0,0 1,-0.65 -0.65V32.55A0.65,0.65 0,0 1,13.94 31.9Z"
+ android:strokeAlpha="0.75" android:strokeColor="#000" android:strokeWidth="2.5"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml
new file mode 100644
index 000000000..c510e136e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_plus_depressed.xml
@@ -0,0 +1,9 @@
+<vector android:alpha="0.6" android:height="69.95dp"
+ android:viewportHeight="69.95" android:viewportWidth="69.95"
+ android:width="69.95dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M34.97,34.97m-34.97,0a34.97,34.97 0,1 1,69.94 0a34.97,34.97 0,1 1,-69.94 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M13.94,31.9H31.16a0.65,0.65 0,0 0,0.65 -0.64V14.59a0.65,0.65 0,0 1,0.64 -0.65h5a0.65,0.65 0,0 1,0.65 0.65V31.26a0.65,0.65 0,0 0,0.65 0.64H56a0.65,0.65 0,0 1,0.65 0.65V37.4a0.65,0.65 0,0 1,-0.65 0.65H38.79a0.65,0.65 0,0 0,-0.65 0.64V55.36a0.65,0.65 0,0 1,-0.65 0.64h-5a0.64,0.64 0,0 1,-0.64 -0.64V38.69a0.65,0.65 0,0 0,-0.65 -0.64H13.94a0.65,0.65 0,0 1,-0.65 -0.65V32.55A0.65,0.65 0,0 1,13.94 31.9Z"
+ android:strokeAlpha="0.75" android:strokeColor="#fff" android:strokeWidth="2.5"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_screenshot.xml b/src/android/app/src/main/res/drawable/facebutton_screenshot.xml
new file mode 100644
index 000000000..984b4fd02
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_screenshot.xml
@@ -0,0 +1,21 @@
+<vector android:alpha="0.6" android:height="70dp"
+ android:viewportHeight="70" android:viewportWidth="70"
+ android:width="70dp" xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.75"
+ android:pathData="M10.63,0L59.37,0A10.63,10.63 0,0 1,70 10.63L70,59.37A10.63,10.63 0,0 1,59.37 70L10.63,70A10.63,10.63 0,0 1,0 59.37L0,10.63A10.63,10.63 0,0 1,10.63 0z" android:strokeAlpha="0.75">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="35" android:centerY="35"
+ android:gradientRadius="42.51" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M35,35m-21.5,0a21.5,21.5 0,1 1,43 0a21.5,21.5 0,1 1,-43 0" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml
new file mode 100644
index 000000000..fd2e44294
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_screenshot_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="70dp"
+ android:viewportHeight="70" android:viewportWidth="70"
+ android:width="70dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M10.63,0L59.37,0A10.63,10.63 0,0 1,70 10.63L70,59.37A10.63,10.63 0,0 1,59.37 70L10.63,70A10.63,10.63 0,0 1,0 59.37L0,10.63A10.63,10.63 0,0 1,10.63 0z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M35,35m-21.5,0a21.5,21.5 0,1 1,43 0a21.5,21.5 0,1 1,-43 0" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_x.xml b/src/android/app/src/main/res/drawable/facebutton_x.xml
new file mode 100644
index 000000000..43fdd14c4
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_x.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="51.8" android:centerY="51.8"
+ android:gradientRadius="51.8" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.6" android:fillColor="#FF000000"
+ android:pathData="M48.39,50.91 L36.63,34.31h6.08L51.8,47.75l9,-13.44h5.93L55.07,50.86 67.92,69.3H61.69l-10,-15.17L41.57,69.3H35.69Z" android:strokeAlpha="0.6"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml
new file mode 100644
index 000000000..a9ba49169
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_x_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M48.39,50.91 L36.63,34.31h6.08L51.8,47.75l9,-13.44h5.93L55.07,50.86 67.92,69.3H61.69l-10,-15.17L41.57,69.3H35.69Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_y.xml b/src/android/app/src/main/res/drawable/facebutton_y.xml
new file mode 100644
index 000000000..980be3b2e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_y.xml
@@ -0,0 +1,22 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="51.8" android:centerY="51.8"
+ android:gradientRadius="51.8" android:type="radial">
+ <item android:color="#FFC3C4C5" android:offset="0.58"/>
+ <item android:color="#FFC6C6C6" android:offset="0.84"/>
+ <item android:color="#FFC7C7C7" android:offset="0.88"/>
+ <item android:color="#FFC2C2C2" android:offset="0.91"/>
+ <item android:color="#FFB5B5B5" android:offset="0.94"/>
+ <item android:color="#FF9E9E9E" android:offset="0.98"/>
+ <item android:color="#FF8F8F8F" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.6" android:fillColor="#FF000000"
+ android:pathData="M49.43,54.37l-13.23,-20h6.07L51.8,49.68l9.83,-15.36h5.78l-13.24,20V69.29H49.43Z" android:strokeAlpha="0.6"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml b/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml
new file mode 100644
index 000000000..320d63897
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/facebutton_y_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="103.61dp"
+ android:viewportHeight="103.61" android:viewportWidth="103.61"
+ android:width="103.61dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M51.8,51.8m-51.8,0a51.8,51.8 0,1 1,103.6 0a51.8,51.8 0,1 1,-103.6 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M49.43,54.37l-13.23,-20h6.07L51.8,49.68l9.83,-15.36h5.78l-13.24,20V69.29H49.43Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 000000000..f7deb2532
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_arrow_forward.xml b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml
new file mode 100644
index 000000000..3b85a3e2c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_arrow_forward.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_back.xml b/src/android/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 000000000..f99fea719
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_cartridge.xml b/src/android/app/src/main/res/drawable/ic_cartridge.xml
new file mode 100644
index 000000000..b332d9c0a
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_cartridge.xml
@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,7c-3.44,0 -4.16,0.35 -4.31,0.5s0,0 0,0.17v8.91a0.38,0.38 0,0 0,0.37 0.37h7.85a0.38,0.38 0,0 0,0.38 -0.37V7.53S15.41,7 12,7Z" />
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M22,6.51a23.12,23.12 0,0 0,-9.75 -2.1A26.09,26.09 0,0 0,2.05 6.5a1.43,1.43 0,0 0,-0.84 1.3L1.21,18.41A1.19,1.19 0,0 0,2.4 19.6L21.57,19.6a1.19,1.19 0,0 0,1.19 -1.19L22.76,7.81A1.43,1.43 0,0 0,22 6.51ZM5.56,18.59h-1v-12l1,-0.3ZM17.29,16.59A1.38,1.38 0,0 1,15.91 18L8,18a1.37,1.37 0,0 1,-1.37 -1.37L6.63,7.73A1.13,1.13 0,0 1,7 6.84c0.41,-0.41 1.3,-0.8 5,-0.8s4.57,0.38 5,0.79a1.12,1.12 0,0 1,0.31 0.87ZM18.39,18.59L18.39,6.26c0.33,0.09 0.66,0.19 1,0.31v12Z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml b/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml
new file mode 100644
index 000000000..cc35d7eff
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_cartridge_outline.xml
@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M22,6.5c-3.1,-1.4 -6.4,-2.1 -9.7,-2.1C8.7,4.4 5.3,5.1 2,6.5C1.5,6.7 1.2,7.2 1.2,7.8v10.6c0,0.7 0.5,1.2 1.2,1.2h19.2c0.7,0 1.2,-0.5 1.2,-1.2c0,0 0,0 0,0l0,0V7.8C22.8,7.3 22.5,6.7 22,6.5zM4.6,18.6H3.6v-3.2c0,-0.2 -0.1,-0.3 -0.2,-0.4c-0.4,-0.2 -0.8,-0.4 -1.2,-0.5V7.8c0,-0.2 0.1,-0.3 0.2,-0.4c0.7,-0.3 1.4,-0.6 2.1,-0.8V18.6zM18.4,18.6H5.6V6.3c2.2,-0.6 4.4,-0.9 6.7,-0.9c2.1,0 4.1,0.3 6.1,0.8L18.4,18.6zM21.8,14.5c-0.4,0.1 -0.8,0.3 -1.2,0.5c-0.1,0.1 -0.2,0.2 -0.2,0.4v3.2h-1v-12c0.7,0.2 1.5,0.5 2.2,0.8c0.1,0.1 0.2,0.2 0.2,0.4L21.8,14.5z" />
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M17,6.8C16.5,6.4 15.7,6 12,6S7.4,6.4 7,6.8C6.8,7.1 6.6,7.4 6.7,7.7v8.9C6.7,17.4 7.3,18 8,18h7.9c0.8,0 1.4,-0.6 1.4,-1.4l0,0V7.7C17.3,7.4 17.2,7.1 17,6.8zM16.2,15.9c0,0.6 -0.5,1.1 -1.1,1.1H8.9c-0.6,0 -1.1,-0.5 -1.1,-1.1V8.4c0,-0.3 0.1,-0.5 0.2,-0.8C8.4,7.3 9,7 12,7s3.6,0.3 4,0.7c0.2,0.2 0.2,0.5 0.2,0.8V15.9z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_check.xml b/src/android/app/src/main/res/drawable/ic_check.xml
new file mode 100644
index 000000000..04b89abf2
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_check.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_check_circle.xml b/src/android/app/src/main/res/drawable/ic_check_circle.xml
new file mode 100644
index 000000000..49e6ecd71
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_check_circle.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_clear.xml b/src/android/app/src/main/res/drawable/ic_clear.xml
new file mode 100644
index 000000000..b6edb1d32
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_clear.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_controller.xml b/src/android/app/src/main/res/drawable/ic_controller.xml
new file mode 100644
index 000000000..060cd9ae2
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_controller.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M21,6L3,6c-1.1,0 -2,0.9 -2,2v8c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2L23,8c0,-1.1 -0.9,-2 -2,-2zM11,13L8,13v3L6,16v-3L3,13v-2h3L6,8h2v3h3v2zM15.5,15c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM19.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S18.67,9 19.5,9s1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_diamond.xml b/src/android/app/src/main/res/drawable/ic_diamond.xml
new file mode 100644
index 000000000..3896e12e4
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_diamond.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M19,3H5L2,9l10,12L22,9L19,3zM9.62,8l1.5,-3h1.76l1.5,3H9.62zM11,10v6.68L5.44,10H11zM13,10h5.56L13,16.68V10zM19.26,8h-2.65l-1.5,-3h2.65L19.26,8zM6.24,5h2.65l-1.5,3H4.74L6.24,5z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_discord.xml b/src/android/app/src/main/res/drawable/ic_discord.xml
new file mode 100644
index 000000000..7a9c6ba79
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_discord.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="200dp"
+ android:height="200dp"
+ android:viewportWidth="256"
+ android:viewportHeight="256">
+ <path
+ android:fillColor="#5865F2"
+ android:fillType="nonZero"
+ android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_exit.xml b/src/android/app/src/main/res/drawable/ic_exit.xml
new file mode 100644
index 000000000..a55a1d387
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_exit.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:autoMirrored="true"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M10.09,15.59L11.5,17l5,-5 -5,-5 -1.41,1.41L12.67,11H3v2h9.67l-2.58,2.59zM19,3H5c-1.11,0 -2,0.9 -2,2v4h2V5h14v14H5v-4H3v4c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_firmware.xml b/src/android/app/src/main/res/drawable/ic_firmware.xml
new file mode 100644
index 000000000..61f3485e4
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_firmware.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M160,840Q127,840 103.5,816.5Q80,793 80,760L80,200Q80,167 103.5,143.5Q127,120 160,120L720,120Q753,120 776.5,143.5Q800,167 800,200L800,280L840,280Q857,280 868.5,291.5Q880,303 880,320Q880,337 868.5,348.5Q857,360 840,360L800,360L800,440L840,440Q857,440 868.5,451.5Q880,463 880,480Q880,497 868.5,508.5Q857,520 840,520L800,520L800,600L840,600Q857,600 868.5,611.5Q880,623 880,640Q880,657 868.5,668.5Q857,680 840,680L800,680L800,760Q800,793 776.5,816.5Q753,840 720,840L160,840ZM160,760L720,760Q720,760 720,760Q720,760 720,760L720,200Q720,200 720,200Q720,200 720,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760ZM280,680L400,680Q417,680 428.5,668.5Q440,657 440,640L440,560Q440,543 428.5,531.5Q417,520 400,520L280,520Q263,520 251.5,531.5Q240,543 240,560L240,640Q240,657 251.5,668.5Q263,680 280,680ZM520,400L600,400Q617,400 628.5,388.5Q640,377 640,360L640,320Q640,303 628.5,291.5Q617,280 600,280L520,280Q503,280 491.5,291.5Q480,303 480,320L480,360Q480,377 491.5,388.5Q503,400 520,400ZM280,480L400,480Q417,480 428.5,468.5Q440,457 440,440L440,320Q440,303 428.5,291.5Q417,280 400,280L280,280Q263,280 251.5,291.5Q240,303 240,320L240,440Q240,457 251.5,468.5Q263,480 280,480ZM520,680L600,680Q617,680 628.5,668.5Q640,657 640,640L640,480Q640,463 628.5,451.5Q617,440 600,440L520,440Q503,440 491.5,451.5Q480,463 480,480L480,640Q480,657 491.5,668.5Q503,680 520,680ZM160,200L160,200Q160,200 160,200Q160,200 160,200L160,760Q160,760 160,760Q160,760 160,760L160,760Q160,760 160,760Q160,760 160,760L160,200Q160,200 160,200Q160,200 160,200Z"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_folder_open.xml b/src/android/app/src/main/res/drawable/ic_folder_open.xml
new file mode 100644
index 000000000..7958fdaec
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_folder_open.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M4.17,20.48C3.431,20.48 2.805,20.223 2.291,19.709C1.777,19.195 1.52,18.569 1.52,17.83V5.958C1.52,5.21 1.777,4.581 2.291,4.072C2.805,3.562 3.431,3.308 4.17,3.308H9.788L12,5.52H19.83C20.578,5.52 21.207,5.777 21.716,6.291C22.226,6.805 22.48,7.431 22.48,8.17H4.17V17.901L6.57,10.17H23.938L21.448,18.194C21.201,18.957 20.817,19.528 20.294,19.909C19.77,20.29 19.118,20.48 18.336,20.48H4.17Z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_github.xml b/src/android/app/src/main/res/drawable/ic_github.xml
new file mode 100644
index 000000000..c2ee43803
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_github.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="98dp"
+ android:height="96dp"
+ android:viewportWidth="98"
+ android:viewportHeight="96">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:fillType="evenOdd"
+ android:pathData="M48.854,0C21.839,0 0,22 0,49.217c0,21.756 13.993,40.172 33.405,46.69 2.427,0.49 3.316,-1.059 3.316,-2.362 0,-1.141 -0.08,-5.052 -0.08,-9.127 -13.59,2.934 -16.42,-5.867 -16.42,-5.867 -2.184,-5.704 -5.42,-7.17 -5.42,-7.17 -4.448,-3.015 0.324,-3.015 0.324,-3.015 4.934,0.326 7.523,5.052 7.523,5.052 4.367,7.496 11.404,5.378 14.235,4.074 0.404,-3.178 1.699,-5.378 3.074,-6.6 -10.839,-1.141 -22.243,-5.378 -22.243,-24.283 0,-5.378 1.94,-9.778 5.014,-13.2 -0.485,-1.222 -2.184,-6.275 0.486,-13.038 0,0 4.125,-1.304 13.426,5.052a46.97,46.97 0,0 1,12.214 -1.63c4.125,0 8.33,0.571 12.213,1.63 9.302,-6.356 13.427,-5.052 13.427,-5.052 2.67,6.763 0.97,11.816 0.485,13.038 3.155,3.422 5.015,7.822 5.015,13.2 0,18.905 -11.404,23.06 -22.324,24.283 1.78,1.548 3.316,4.481 3.316,9.126 0,6.6 -0.08,11.897 -0.08,13.526 0,1.304 0.89,2.853 3.316,2.364 19.412,-6.52 33.405,-24.935 33.405,-46.691C97.707,22 75.788,0 48.854,0z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_icon_bg.xml b/src/android/app/src/main/res/drawable/ic_icon_bg.xml
new file mode 100644
index 000000000..df62dde92
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_icon_bg.xml
@@ -0,0 +1,751 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="512dp"
+ android:height="512dp"
+ android:viewportWidth="512"
+ android:viewportHeight="512">
+ <group>
+ <clip-path
+ android:pathData="M0,0h512v512h-512z"/>
+ <path
+ android:pathData="M0,0h512v512h-512z"
+ android:fillColor="#ffffff"/>
+ <path
+ android:pathData="M0,0h512v512h-512z"
+ android:fillColor="#1C1C1C"/>
+ <path
+ android:pathData="M208.16,7H159.88C155.54,7 152,10.54 152,14.88V92.16C152,96.54 155.54,100.04 159.88,100.04H208.12C212.5,100.04 216,96.5 216,92.16V14.88C216.04,10.54 212.5,7 208.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M208.8,89.73H158.44C156.65,89.73 155.18,88.26 155.18,86.47V17.02C155.18,15.23 156.65,13.76 158.44,13.76H208.84C210.63,13.76 212.1,15.23 212.1,17.02V86.51C212.06,88.26 210.59,89.73 208.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M194.16,14.16H173.08V12.93C173.08,12.29 173.6,11.77 174.24,11.77H193.01C193.65,11.77 194.16,12.29 194.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M183.86,97.29L177.93,92.92H189.79L183.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M424.16,7H375.88C371.54,7 368,10.54 368,14.88V92.16C368,96.54 371.54,100.04 375.88,100.04H424.12C428.5,100.04 432,96.5 432,92.16V14.88C432.04,10.54 428.5,7 424.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M424.8,89.73H374.44C372.65,89.73 371.18,88.26 371.18,86.47V17.02C371.18,15.23 372.65,13.76 374.44,13.76H424.84C426.63,13.76 428.1,15.23 428.1,17.02V86.51C428.06,88.26 426.59,89.73 424.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M410.16,14.16H389.08V12.93C389.08,12.29 389.6,11.77 390.23,11.77H409.01C409.65,11.77 410.16,12.29 410.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M399.86,97.29L393.93,92.92H405.79L399.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.16,109H303.88C299.54,109 296,112.54 296,116.88V194.16C296,198.54 299.54,202.04 303.88,202.04H352.12C356.5,202.04 360,198.5 360,194.16V116.88C360.04,112.54 356.5,109 352.16,109Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.8,191.73H302.44C300.65,191.73 299.18,190.26 299.18,188.47V119.02C299.18,117.23 300.65,115.76 302.44,115.76H352.84C354.63,115.76 356.1,117.23 356.1,119.02V188.51C356.06,190.26 354.59,191.73 352.8,191.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M338.16,116.16H317.08V114.93C317.08,114.29 317.6,113.77 318.23,113.77H337.01C337.65,113.77 338.16,114.29 338.16,114.93V116.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M327.86,199.29L321.93,194.92H333.79L327.86,199.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.16,7H447.88C443.54,7 440,10.54 440,14.88V92.16C440,96.54 443.54,100.04 447.88,100.04H496.12C500.5,100.04 504,96.5 504,92.16V14.88C504.04,10.54 500.5,7 496.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.8,89.73H446.44C444.65,89.73 443.18,88.26 443.18,86.47V17.02C443.18,15.23 444.65,13.76 446.44,13.76H496.84C498.63,13.76 500.1,15.23 500.1,17.02V86.51C500.06,88.26 498.59,89.73 496.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M482.16,14.16H461.08V12.93C461.08,12.29 461.6,11.77 462.23,11.77H481.01C481.65,11.77 482.16,12.29 482.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M471.86,97.29L465.93,92.92H477.79L471.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.16,7H303.88C299.54,7 296,10.54 296,14.88V92.16C296,96.54 299.54,100.04 303.88,100.04H352.12C356.5,100.04 360,96.5 360,92.16V14.88C360.04,10.54 356.5,7 352.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.8,89.73H302.44C300.65,89.73 299.18,88.26 299.18,86.47V17.02C299.18,15.23 300.65,13.76 302.44,13.76H352.84C354.63,13.76 356.1,15.23 356.1,17.02V86.51C356.06,88.26 354.59,89.73 352.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M338.16,14.16H317.08V12.93C317.08,12.29 317.6,11.77 318.23,11.77H337.01C337.65,11.77 338.16,12.29 338.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M327.86,97.29L321.93,92.92H333.79L327.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.16,7H231.88C227.54,7 224,10.54 224,14.88V92.16C224,96.54 227.54,100.04 231.88,100.04H280.12C284.5,100.04 288,96.5 288,92.16V14.88C288.04,10.54 284.5,7 280.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.8,89.73H230.44C228.65,89.73 227.18,88.26 227.18,86.47V17.02C227.18,15.23 228.65,13.76 230.44,13.76H280.84C282.63,13.76 284.1,15.23 284.1,17.02V86.51C284.06,88.26 282.59,89.73 280.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M266.16,14.16H245.08V12.93C245.08,12.29 245.6,11.77 246.24,11.77H265.01C265.65,11.77 266.16,12.29 266.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M255.86,97.29L249.93,92.92H261.79L255.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M424.16,109H375.88C371.54,109 368,112.54 368,116.88V194.16C368,198.54 371.54,202.04 375.88,202.04H424.12C428.5,202.04 432,198.5 432,194.16V116.88C432.04,112.54 428.5,109 424.16,109Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M135.16,411H86.88C82.54,411 79,414.54 79,418.88V496.16C79,500.54 82.54,504.04 86.88,504.04H135.12C139.5,504.04 143,500.5 143,496.16V418.88C143.04,414.54 139.5,411 135.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.16,7H15.88C11.54,7 8,10.54 8,14.88V92.16C8,96.54 11.54,100.04 15.88,100.04H64.12C68.5,100.04 72,96.5 72,92.16V14.88C72.04,10.54 68.5,7 64.16,7Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.8,89.73H14.44C12.65,89.73 11.18,88.26 11.18,86.47V17.02C11.18,15.23 12.65,13.76 14.44,13.76H64.84C66.63,13.76 68.1,15.23 68.1,17.02V86.51C68.06,88.26 66.59,89.73 64.8,89.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M50.16,14.16H29.08V12.93C29.08,12.29 29.6,11.77 30.23,11.77H49.01C49.65,11.77 50.16,12.29 50.16,12.93V14.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M39.86,97.29L33.93,92.92H45.79L39.86,97.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M63.16,310H14.88C10.54,310 7,313.54 7,317.88V395.16C7,399.54 10.54,403.04 14.88,403.04H63.12C67.5,403.04 71,399.5 71,395.16V317.88C71.04,313.54 67.5,310 63.16,310Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M63.8,392.73H13.44C11.65,392.73 10.18,391.26 10.18,389.47V320.02C10.18,318.23 11.65,316.76 13.44,316.76H63.84C65.63,316.76 67.1,318.23 67.1,320.02V389.51C67.06,391.26 65.59,392.73 63.8,392.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M49.16,317.16H28.08V315.93C28.08,315.29 28.6,314.77 29.23,314.77H48.01C48.65,314.77 49.16,315.29 49.16,315.93V317.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M38.86,400.29L32.93,395.92H44.79L38.86,400.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M424.16,209H375.88C371.54,209 368,212.54 368,216.88V294.16C368,298.54 371.54,302.04 375.88,302.04H424.12C428.5,302.04 432,298.5 432,294.16V216.88C432.04,212.54 428.5,209 424.16,209Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M424.8,291.73H374.44C372.65,291.73 371.18,290.26 371.18,288.47V219.02C371.18,217.23 372.65,215.76 374.44,215.76H424.84C426.63,215.76 428.1,217.23 428.1,219.02V288.51C428.06,290.26 426.59,291.73 424.8,291.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M410.16,216.16H389.08V214.93C389.08,214.29 389.6,213.77 390.23,213.77H409.01C409.65,213.77 410.16,214.29 410.16,214.93V216.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M399.86,299.29L393.93,294.92H405.79L399.86,299.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.16,209H447.88C443.54,209 440,212.54 440,216.88V294.16C440,298.54 443.54,302.04 447.88,302.04H496.12C500.5,302.04 504,298.5 504,294.16V216.88C504.04,212.54 500.5,209 496.16,209Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.8,291.73H446.44C444.65,291.73 443.18,290.26 443.18,288.47V219.02C443.18,217.23 444.65,215.76 446.44,215.76H496.84C498.63,215.76 500.1,217.23 500.1,219.02V288.51C500.06,290.26 498.59,291.73 496.8,291.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M482.16,216.16H461.08V214.93C461.08,214.29 461.6,213.77 462.23,213.77H481.01C481.65,213.77 482.16,214.29 482.16,214.93V216.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M471.86,299.29L465.93,294.92H477.79L471.86,299.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M136.16,209H87.88C83.54,209 80,212.54 80,216.88V294.16C80,298.54 83.54,302.04 87.88,302.04H136.12C140.5,302.04 144,298.5 144,294.16V216.88C144.04,212.54 140.5,209 136.16,209Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M136.8,291.73H86.44C84.65,291.73 83.18,290.26 83.18,288.47V219.02C83.18,217.23 84.65,215.76 86.44,215.76H136.84C138.63,215.76 140.1,217.23 140.1,219.02V288.51C140.06,290.26 138.59,291.73 136.8,291.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M122.16,216.16H101.08V214.93C101.08,214.29 101.6,213.77 102.24,213.77H121.01C121.65,213.77 122.16,214.29 122.16,214.93V216.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M111.86,299.29L105.93,294.92H117.79L111.86,299.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.16,209H303.88C299.54,209 296,212.54 296,216.88V294.16C296,298.54 299.54,302.04 303.88,302.04H352.12C356.5,302.04 360,298.5 360,294.16V216.88C360.04,212.54 356.5,209 352.16,209Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.8,291.73H302.44C300.65,291.73 299.18,290.26 299.18,288.47V219.02C299.18,217.23 300.65,215.76 302.44,215.76H352.84C354.63,215.76 356.1,217.23 356.1,219.02V288.51C356.06,290.26 354.59,291.73 352.8,291.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M338.16,216.16H317.08V214.93C317.08,214.29 317.6,213.77 318.23,213.77H337.01C337.65,213.77 338.16,214.29 338.16,214.93V216.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M327.86,299.29L321.93,294.92H333.79L327.86,299.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.16,209H15.88C11.54,209 8,212.54 8,216.88V294.16C8,298.54 11.54,302.04 15.88,302.04H64.12C68.5,302.04 72,298.5 72,294.16V216.88C72.04,212.54 68.5,209 64.16,209Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.8,291.73H14.44C12.65,291.73 11.18,290.26 11.18,288.47V219.02C11.18,217.23 12.65,215.76 14.44,215.76H64.84C66.63,215.76 68.1,217.23 68.1,219.02V288.51C68.06,290.26 66.59,291.73 64.8,291.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M50.16,216.16H29.08V214.93C29.08,214.29 29.6,213.77 30.23,213.77H49.01C49.65,213.77 50.16,214.29 50.16,214.93V216.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M39.86,299.29L33.93,294.92H45.79L39.86,299.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M135.16,310H86.88C82.54,310 79,313.54 79,317.88V395.16C79,399.54 82.54,403.04 86.88,403.04H135.12C139.5,403.04 143,399.5 143,395.16V317.88C143.04,313.54 139.5,310 135.16,310Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M135.8,392.73H85.44C83.65,392.73 82.18,391.26 82.18,389.47V320.02C82.18,318.23 83.65,316.76 85.44,316.76H135.84C137.63,316.76 139.1,318.23 139.1,320.02V389.51C139.06,391.26 137.59,392.73 135.8,392.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M121.16,317.16H100.08V315.93C100.08,315.29 100.6,314.77 101.24,314.77H120.01C120.65,314.77 121.16,315.29 121.16,315.93V317.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M110.86,400.29L104.93,395.92H116.79L110.86,400.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M208.16,108H159.88C155.54,108 152,111.54 152,115.88V193.16C152,197.54 155.54,201.04 159.88,201.04H208.12C212.5,201.04 216,197.5 216,193.16V115.88C216.04,111.54 212.5,108 208.16,108Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M208.8,190.73H158.44C156.65,190.73 155.18,189.26 155.18,187.47V118.02C155.18,116.23 156.65,114.76 158.44,114.76H208.84C210.63,114.76 212.1,116.23 212.1,118.02V187.51C212.06,189.26 210.59,190.73 208.8,190.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M194.16,115.16H173.08V113.93C173.08,113.29 173.6,112.77 174.24,112.77H193.01C193.65,112.77 194.16,113.29 194.16,113.93V115.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M183.86,198.29L177.93,193.92H189.79L183.86,198.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.16,108H447.88C443.54,108 440,111.54 440,115.88V193.16C440,197.54 443.54,201.04 447.88,201.04H496.12C500.5,201.04 504,197.5 504,193.16V115.88C504.04,111.54 500.5,108 496.16,108Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.8,190.73H446.44C444.65,190.73 443.18,189.26 443.18,187.47V118.02C443.18,116.23 444.65,114.76 446.44,114.76H496.84C498.63,114.76 500.1,116.23 500.1,118.02V187.51C500.06,189.26 498.59,190.73 496.8,190.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M482.16,115.16H461.08V113.93C461.08,113.29 461.6,112.77 462.23,112.77H481.01C481.65,112.77 482.16,113.29 482.16,113.93V115.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M471.86,198.29L465.93,193.92H477.79L471.86,198.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.16,108H15.88C11.54,108 8,111.54 8,115.88V193.16C8,197.54 11.54,201.04 15.88,201.04H64.12C68.5,201.04 72,197.5 72,193.16V115.88C72.04,111.54 68.5,108 64.16,108Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M64.8,190.73H14.44C12.65,190.73 11.18,189.26 11.18,187.47V118.02C11.18,116.23 12.65,114.76 14.44,114.76H64.84C66.63,114.76 68.1,116.23 68.1,118.02V187.51C68.06,189.26 66.59,190.73 64.8,190.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M50.16,115.16H29.08V113.93C29.08,113.29 29.6,112.77 30.23,112.77H49.01C49.65,112.77 50.16,113.29 50.16,113.93V115.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M39.86,198.29L33.93,193.92H45.79L39.86,198.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.16,108H231.88C227.54,108 224,111.54 224,115.88V193.16C224,197.54 227.54,201.04 231.88,201.04H280.12C284.5,201.04 288,197.5 288,193.16V115.88C288.04,111.54 284.5,108 280.16,108Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.8,190.73H230.44C228.65,190.73 227.18,189.26 227.18,187.47V118.02C227.18,116.23 228.65,114.76 230.44,114.76H280.84C282.63,114.76 284.1,116.23 284.1,118.02V187.51C284.06,189.26 282.59,190.73 280.8,190.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M266.16,115.16H245.08V113.93C245.08,113.29 245.6,112.77 246.24,112.77H265.01C265.65,112.77 266.16,113.29 266.16,113.93V115.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M255.86,198.29L249.93,193.92H261.79L255.86,198.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.16,310H447.88C443.54,310 440,313.54 440,317.88V395.16C440,399.54 443.54,403.04 447.88,403.04H496.12C500.5,403.04 504,399.5 504,395.16V317.88C504.04,313.54 500.5,310 496.16,310Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.8,392.73H446.44C444.65,392.73 443.18,391.26 443.18,389.47V320.02C443.18,318.23 444.65,316.76 446.44,316.76H496.84C498.63,316.76 500.1,318.23 500.1,320.02V389.51C500.06,391.26 498.59,392.73 496.8,392.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M482.16,317.16H461.08V315.93C461.08,315.29 461.6,314.77 462.23,314.77H481.01C481.65,314.77 482.16,315.29 482.16,315.93V317.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M471.86,400.29L465.93,395.92H477.79L471.86,400.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.16,310H303.88C299.54,310 296,313.54 296,317.88V395.16C296,399.54 299.54,403.04 303.88,403.04H352.12C356.5,403.04 360,399.5 360,395.16V317.88C360.04,313.54 356.5,310 352.16,310Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.8,392.73H302.44C300.65,392.73 299.18,391.26 299.18,389.47V320.02C299.18,318.23 300.65,316.76 302.44,316.76H352.84C354.63,316.76 356.1,318.23 356.1,320.02V389.51C356.06,391.26 354.59,392.73 352.8,392.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M338.16,317.16H317.08V315.93C317.08,315.29 317.6,314.77 318.23,314.77H337.01C337.65,314.77 338.16,315.29 338.16,315.93V317.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M327.86,400.29L321.93,395.92H333.79L327.86,400.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M63.16,411H14.88C10.54,411 7,414.54 7,418.88V496.16C7,500.54 10.54,504.04 14.88,504.04H63.12C67.5,504.04 71,500.5 71,496.16V418.88C71.04,414.54 67.5,411 63.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M63.8,493.73H13.44C11.65,493.73 10.18,492.26 10.18,490.47V421.02C10.18,419.23 11.65,417.76 13.44,417.76H63.84C65.63,417.76 67.1,419.23 67.1,421.02V490.51C67.06,492.26 65.59,493.73 63.8,493.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M49.16,418.16H28.08V416.93C28.08,416.29 28.6,415.77 29.23,415.77H48.01C48.65,415.77 49.16,416.29 49.16,416.93V418.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M38.86,501.29L32.93,496.92H44.79L38.86,501.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.16,411H447.88C443.54,411 440,414.54 440,418.88V496.16C440,500.54 443.54,504.04 447.88,504.04H496.12C500.5,504.04 504,500.5 504,496.16V418.88C504.04,414.54 500.5,411 496.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M496.8,493.73H446.44C444.65,493.73 443.18,492.26 443.18,490.47V421.02C443.18,419.23 444.65,417.76 446.44,417.76H496.84C498.63,417.76 500.1,419.23 500.1,421.02V490.51C500.06,492.26 498.59,493.73 496.8,493.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M482.16,418.16H461.08V416.93C461.08,416.29 461.6,415.77 462.23,415.77H481.01C481.65,415.77 482.16,416.29 482.16,416.93V418.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M471.86,501.29L465.93,496.92H477.79L471.86,501.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.16,411H303.88C299.54,411 296,414.54 296,418.88V496.16C296,500.54 299.54,504.04 303.88,504.04H352.12C356.5,504.04 360,500.5 360,496.16V418.88C360.04,414.54 356.5,411 352.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M352.8,493.73H302.44C300.65,493.73 299.18,492.26 299.18,490.47V421.02C299.18,419.23 300.65,417.76 302.44,417.76H352.84C354.63,417.76 356.1,419.23 356.1,421.02V490.51C356.06,492.26 354.59,493.73 352.8,493.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M338.16,418.16H317.08V416.93C317.08,416.29 317.6,415.77 318.23,415.77H337.01C337.65,415.77 338.16,416.29 338.16,416.93V418.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M327.86,501.29L321.93,496.92H333.79L327.86,501.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M208.16,411H159.88C155.54,411 152,414.54 152,418.88V496.16C152,500.54 155.54,504.04 159.88,504.04H208.12C212.5,504.04 216,500.5 216,496.16V418.88C216.04,414.54 212.5,411 208.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M208.8,493.73H158.44C156.65,493.73 155.18,492.26 155.18,490.47V421.02C155.18,419.23 156.65,417.76 158.44,417.76H208.84C210.63,417.76 212.1,419.23 212.1,421.02V490.51C212.06,492.26 210.59,493.73 208.8,493.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M194.16,418.16H173.08V416.93C173.08,416.29 173.6,415.77 174.24,415.77H193.01C193.65,415.77 194.16,416.29 194.16,416.93V418.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M183.86,501.29L177.93,496.92H189.79L183.86,501.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.16,411H231.88C227.54,411 224,414.54 224,418.88V496.16C224,500.54 227.54,504.04 231.88,504.04H280.12C284.5,504.04 288,500.5 288,496.16V418.88C288.04,414.54 284.5,411 280.16,411Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M280.8,493.73H230.44C228.65,493.73 227.18,492.26 227.18,490.47V421.02C227.18,419.23 228.65,417.76 230.44,417.76H280.84C282.63,417.76 284.1,419.23 284.1,421.02V490.51C284.06,492.26 282.59,493.73 280.8,493.73Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M266.16,418.16H245.08V416.93C245.08,416.29 245.6,415.77 246.24,415.77H265.01C265.65,415.77 266.16,416.29 266.16,416.93V418.16Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M255.86,501.29L249.93,496.92H261.79L255.86,501.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <group>
+ <clip-path
+ android:pathData="M80,8h64v192h-64z"/>
+ <path
+ android:pathData="M112.06,8H144.11V200H112.06C94.32,200 80,185.68 80,167.96V40.04C80,22.31 94.32,8 112.06,8Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M138.2,26.4H128.43C128.31,26.4 128.31,26.29 128.31,26.18V23.79C128.31,23.68 128.43,23.56 128.43,23.56H138.2C138.32,23.56 138.32,23.68 138.32,23.79V26.18C138.32,26.29 138.2,26.4 138.2,26.4Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M129.9,142.85V147.63C129.9,149.67 128.31,151.26 126.27,151.26H121.49C119.45,151.26 117.85,149.67 117.85,147.63V142.85C117.85,140.81 119.45,139.22 121.49,139.22H126.27C128.31,139.33 129.9,140.92 129.9,142.85Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M113.76,65.26C120.1,65.26 125.24,60.12 125.24,53.78C125.24,47.45 120.1,42.31 113.76,42.31C107.42,42.31 102.28,47.45 102.28,53.78C102.28,60.12 107.42,65.26 113.76,65.26Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M112.85,39.02V40.95C112.85,40.95 112.85,41.06 112.74,41.06C106.49,41.51 101.49,46.51 100.92,52.88C100.92,52.88 100.92,52.99 100.8,52.99H98.98C98.98,52.99 98.87,52.99 98.87,52.88C98.87,52.53 98.87,52.31 98.98,51.97C100.01,44.7 105.92,39.47 112.85,39.02Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M128.54,54.69C128.65,55.03 128.54,55.38 128.54,55.72C127.63,62.87 121.72,68.1 114.9,68.55C114.9,68.55 114.79,68.55 114.79,68.44V66.62C114.79,66.62 114.79,66.51 114.9,66.51C121.15,66.05 126.15,61.06 126.72,54.69C126.72,54.69 126.72,54.58 126.83,54.58H128.54V54.69Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M128.54,52.88H126.61C126.61,52.88 126.49,52.88 126.49,52.76C126.04,46.51 121.04,41.51 114.67,40.95C114.67,40.95 114.56,40.95 114.56,40.83V39.02C114.56,39.02 114.56,38.9 114.67,38.9C115.01,38.9 115.24,38.9 115.58,39.02C122.86,40.04 128.09,45.83 128.54,52.88Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M112.85,66.62V68.44C112.85,68.44 112.85,68.55 112.74,68.55C112.4,68.55 112.17,68.55 111.83,68.44C104.67,67.53 99.44,61.62 98.98,54.81C98.98,54.81 98.98,54.69 99.1,54.69H100.92C100.92,54.69 101.03,54.69 101.03,54.81C101.49,61.06 106.49,66.05 112.85,66.62C112.85,66.51 112.85,66.51 112.85,66.62Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M108.08,109.68C108.08,113.66 104.89,116.84 100.92,116.84C96.94,116.84 93.64,113.66 93.64,109.68C93.64,105.7 96.82,102.52 100.92,102.52C104.89,102.52 108.08,105.7 108.08,109.68Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M120.7,97.18C120.7,101.16 117.51,104.34 113.42,104.34C109.44,104.34 106.26,101.16 106.26,97.18C106.26,93.21 109.44,90.03 113.42,90.03C117.4,89.91 120.7,93.21 120.7,97.18Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M133.2,109.68C133.2,113.66 130.02,116.84 126.04,116.84C122.06,116.84 118.88,113.66 118.88,109.68C118.88,105.7 122.06,102.52 126.04,102.52C129.9,102.52 133.2,105.7 133.2,109.68Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M120.7,122.29C120.7,126.27 117.51,129.45 113.42,129.45C109.44,129.45 106.26,126.27 106.26,122.29C106.26,118.32 109.44,115.13 113.42,115.13C117.4,115.02 120.7,118.32 120.7,122.29Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ </group>
+ <path
+ android:pathData="M157.99,209.4C157.87,209.5 157.75,209.7 157.75,210C157.75,210.5 157.63,210.8 157.51,211.01C157.03,211.81 155.83,212.21 154.51,212.21L152.95,212.21C152.95,212.21 152.71,212.21 152.59,212.31C152.47,212.41 152.47,212.51 152.47,212.61L152.47,399.35C152.47,399.45 152.47,399.45 152.47,399.55C152.59,399.75 152.83,399.85 153.07,399.85L154.87,399.85C154.87,399.85 156.31,399.75 157.15,400.65C157.75,401.36 157.75,402.26 157.75,402.26C157.75,402.36 157.75,402.56 157.87,402.66C158.1,402.96 158.46,403.16 159.06,403.16L287.28,403.16C287.4,403.16 287.52,403.16 287.64,403.06C288,402.86 288,402.56 288,402.56L288,209.7C288,209.7 288,209.3 287.76,209.1C287.64,209 287.52,209 287.4,209L159.18,209C159.18,209 158.35,209 157.99,209.4ZM279.85,214.52C279.97,214.52 281.41,214.52 282.49,215.42C283.57,216.32 283.57,217.63 283.57,217.73L283.57,394.54C283.57,394.64 283.57,395.94 282.49,396.84C281.41,397.74 279.97,397.74 279.85,397.74L160.74,397.74C160.62,397.74 159.18,397.74 158.1,396.84C157.03,395.94 157.03,394.64 157.03,394.54L157.03,217.73C157.03,217.63 156.91,216.42 158.1,215.42C159.18,214.52 160.62,214.52 160.74,214.52L279.85,214.52Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M151.36,353.72L152.44,353.72L152.44,377.49L151.36,377.49C151.36,377.49 151,377.39 151,377.09L151,369.87C151,369.87 151,369.47 151.36,369.47L151.36,361.44C151.36,361.44 151,361.44 151,361.14L151,353.92C151.12,353.82 151.12,353.72 151.36,353.72Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M160.78,214.51L279.89,214.51C280.01,214.51 281.45,214.51 282.52,215.41C283.6,216.31 283.6,217.62 283.6,217.72L283.6,394.53C283.6,394.63 283.6,395.93 282.52,396.83C281.45,397.74 280.01,397.74 279.89,397.74L160.78,397.74C160.66,397.74 159.22,397.74 158.14,396.83C157.06,395.93 157.06,394.63 157.06,394.53L157.06,217.72C157.06,217.62 156.95,216.41 158.14,215.41C159.22,214.51 160.66,214.51 160.78,214.51Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <group>
+ <clip-path
+ android:pathData="M368,311h64v192h-64z"/>
+ <path
+ android:pathData="M400.06,311H368V503H400.06C417.79,503 432.11,488.68 432.11,470.96V343.04C432,325.32 417.68,311 400.06,311Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:strokeWidth="1"
+ android:pathData="M374.14,327.81H378.23C378.35,327.81 378.35,327.7 378.35,327.7V323.84C378.35,323.72 378.46,323.72 378.46,323.72H379.6C379.71,323.72 379.71,323.84 379.71,323.84V327.7C379.71,327.81 379.82,327.81 379.82,327.81H383.8C383.92,327.81 383.92,327.93 383.92,327.93V329.06C383.92,329.18 383.8,329.18 383.8,329.18H379.82C379.71,329.18 379.71,329.29 379.71,329.29V333.15C379.71,333.27 379.6,333.27 379.6,333.27H378.46C378.35,333.27 378.35,333.15 378.35,333.15V329.29C378.35,329.18 378.23,329.18 378.23,329.18H374.14C374.02,329.18 374.02,329.06 374.02,329.06V327.93C374.02,327.93 374.02,327.81 374.14,327.81Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:strokeColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M399.49,423.81C405.83,423.81 410.97,418.68 410.97,412.34C410.97,406 405.83,400.86 399.49,400.86C393.15,400.86 388.01,406 388.01,412.34C388.01,418.68 393.15,423.81 399.49,423.81Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M398.58,397.68V399.5C398.58,399.5 398.58,399.61 398.46,399.61C392.21,400.07 387.21,405.07 386.64,411.43C386.64,411.43 386.64,411.54 386.53,411.54H384.71C384.71,411.54 384.6,411.54 384.6,411.43C384.6,411.09 384.6,410.86 384.71,410.52C385.73,403.25 391.64,398.02 398.58,397.68C398.58,397.57 398.58,397.57 398.58,397.68Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M414.27,413.25C414.38,413.59 414.27,413.93 414.27,414.27C413.36,421.43 407.45,426.65 400.63,427.11C400.63,427.11 400.51,427.11 400.51,426.99V425.18C400.51,425.18 400.51,425.06 400.63,425.06C406.88,424.61 411.88,419.61 412.45,413.25C412.45,413.25 412.45,413.14 412.56,413.14H414.27V413.25Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M414.27,411.43H412.33C412.33,411.43 412.22,411.43 412.22,411.32C411.77,405.07 406.76,400.07 400.4,399.5C400.4,399.5 400.28,399.5 400.28,399.39V397.57C400.28,397.57 400.28,397.46 400.4,397.46C400.74,397.46 400.97,397.46 401.31,397.57C408.58,398.59 413.81,404.39 414.27,411.43Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M398.58,425.18V426.99C398.58,426.99 398.58,427.11 398.46,427.11C398.12,427.11 397.9,427.11 397.56,426.99C390.39,426.09 385.17,420.18 384.71,413.36C384.71,413.36 384.71,413.25 384.82,413.25H386.64C386.64,413.25 386.76,413.25 386.76,413.36C387.21,419.61 392.21,424.61 398.58,425.18Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M392.67,358.15C392.67,362.12 389.48,365.3 385.51,365.3C381.53,365.42 378.23,362.12 378.23,358.15C378.23,354.17 381.41,350.99 385.51,350.99C389.48,350.99 392.67,354.17 392.67,358.15Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M405.29,345.65C405.29,349.63 402.1,352.81 398.01,352.81C394.03,352.81 390.85,349.63 390.85,345.65C390.85,341.67 394.03,338.49 398.01,338.49C401.99,338.38 405.29,341.67 405.29,345.65Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M417.79,358.15C417.79,362.12 414.61,365.3 410.63,365.3C406.65,365.3 403.47,362.12 403.47,358.15C403.47,354.17 406.65,350.99 410.63,350.99C414.49,350.99 417.79,354.17 417.79,358.15Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M405.29,370.76C405.29,374.73 402.1,377.92 398.01,377.92C394.03,377.92 390.85,374.73 390.85,370.76C390.85,366.78 394.03,363.6 398.01,363.6C401.99,363.49 405.29,366.78 405.29,370.76Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M394.15,448.81C394.15,452.33 391.3,455.17 387.78,455.17C384.26,455.17 381.41,452.33 381.41,448.81C381.41,445.29 384.26,442.45 387.78,442.45C391.3,442.56 394.15,445.4 394.15,448.81Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ </group>
+ <path
+ android:pathData="M91.95,468.95C97.99,468.95 102.9,464.05 102.9,458C102.9,451.95 97.99,447.05 91.95,447.05C85.9,447.05 81,451.95 81,458C81,464.05 85.9,468.95 91.95,468.95Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1A1A1A"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M88.14,457.83L93.88,454.5C94,454.42 94.17,454.53 94.17,454.67V461.3C94.17,461.44 94.02,461.53 93.88,461.47L88.14,458.14C88.02,458.08 88.02,457.92 88.14,457.83Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M111,449.9C117.05,449.9 121.95,444.99 121.95,438.95C121.95,432.9 117.05,428 111,428C104.95,428 100.05,432.9 100.05,438.95C100.05,444.99 104.95,449.9 111,449.9Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1A1A1A"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M111.17,435.14L114.5,440.88C114.58,440.99 114.47,441.17 114.33,441.17H107.7C107.56,441.17 107.47,441.02 107.53,440.88L110.86,435.14C110.92,435.02 111.08,435.02 111.17,435.14Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M130.05,468.95C136.1,468.95 141,464.05 141,458C141,451.95 136.1,447.05 130.05,447.05C124.01,447.05 119.1,451.95 119.1,458C119.1,464.05 124.01,468.95 130.05,468.95Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1A1A1A"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M133.86,458.17L128.12,461.5C128.01,461.58 127.83,461.47 127.83,461.33V454.7C127.83,454.56 127.98,454.47 128.12,454.53L133.86,457.86C134.01,457.92 134.01,458.08 133.86,458.17Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M111,488C117.05,488 121.95,483.1 121.95,477.05C121.95,471.01 117.05,466.1 111,466.1C104.95,466.1 100.05,471.01 100.05,477.05C100.05,483.1 104.95,488 111,488Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1A1A1A"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M110.83,480.86L107.5,475.12C107.42,475.01 107.53,474.83 107.67,474.83H114.3C114.44,474.83 114.53,474.98 114.47,475.12L111.14,480.86C111.08,481.01 110.92,481.01 110.83,480.86Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M380.95,165.95C386.99,165.95 391.9,161.05 391.9,155C391.9,148.95 386.99,144.05 380.95,144.05C374.9,144.05 370,148.95 370,155C370,161.05 374.9,165.95 380.95,165.95Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M380.46,155.54L377.68,151.3H378.96L380.98,154.54L383.05,151.3H384.27L381.49,155.54V158.7H380.49V155.54H380.46Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M399.72,185C405.76,185 410.66,180.1 410.66,174.05C410.66,168.01 405.76,163.1 399.72,163.1C393.67,163.1 388.77,168.01 388.77,174.05C388.77,180.1 393.67,185 399.72,185Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M397.44,170.64H400C400.31,170.64 400.63,170.67 400.88,170.75C401.17,170.84 401.39,170.95 401.59,171.1C401.79,171.24 401.93,171.44 402.08,171.66C402.19,171.89 402.25,172.18 402.25,172.49C402.25,172.91 402.13,173.26 401.9,173.54C401.68,173.8 401.36,173.99 400.99,174.14V174.17C401.22,174.17 401.42,174.25 401.62,174.34C401.82,174.42 401.99,174.56 402.13,174.74C402.27,174.9 402.39,175.08 402.47,175.3C402.56,175.53 402.59,175.76 402.59,176.01C402.59,176.35 402.53,176.64 402.39,176.9C402.25,177.15 402.08,177.35 401.82,177.55C401.59,177.72 401.31,177.86 400.99,177.95C400.68,178.03 400.34,178.09 399.97,178.09H397.44V170.64ZM398.44,173.71H399.8C400,173.71 400.17,173.68 400.34,173.65C400.51,173.63 400.65,173.54 400.77,173.46C400.88,173.37 400.99,173.26 401.05,173.11C401.14,172.97 401.17,172.8 401.17,172.6C401.17,172.32 401.08,172.06 400.88,171.83C400.68,171.61 400.4,171.52 400,171.52H398.44V173.71ZM398.44,177.15H399.92C400.06,177.15 400.23,177.12 400.43,177.1C400.6,177.07 400.8,177.01 400.94,176.9C401.11,176.81 401.22,176.67 401.34,176.53C401.45,176.35 401.51,176.16 401.51,175.9C401.51,175.47 401.36,175.13 401.08,174.9C400.8,174.68 400.4,174.56 399.92,174.56H398.44V177.15Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M419.05,165.95C425.1,165.95 430,161.05 430,155C430,148.95 425.1,144.05 419.05,144.05C413.01,144.05 408.1,148.95 408.1,155C408.1,161.05 413.01,165.95 419.05,165.95Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M418.63,151.3H419.54L422.69,158.67H421.53L420.79,156.85H417.29L416.55,158.67H415.38L418.63,151.3ZM420.42,155.99L419.05,152.61H419.02L417.63,155.99H420.42Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M400,146.9C406.05,146.9 410.95,141.99 410.95,135.95C410.95,129.9 406.05,125 400,125C393.95,125 389.05,129.9 389.05,135.95C389.05,141.99 393.95,146.9 400,146.9Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#1C1C1C"
+ android:fillAlpha="0.7"/>
+ <path
+ android:pathData="M399.26,135.78L396.79,132.28H398.07L400,135.12L401.9,132.28H403.16L400.68,135.78L403.41,139.67H402.1L399.97,136.46L397.84,139.67H396.59L399.26,135.78Z"
+ android:strokeAlpha="0.7"
+ android:fillColor="#282828"
+ android:fillAlpha="0.7"/>
+ </group>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_info_outline.xml b/src/android/app/src/main/res/drawable/ic_info_outline.xml
new file mode 100644
index 000000000..92ae0eeaf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_info_outline.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_install.xml b/src/android/app/src/main/res/drawable/ic_install.xml
new file mode 100644
index 000000000..01f2de3da
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_install.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,16.5l4,-4h-3v-9h-2v9L8,12.5l4,4zM21,3.5h-6v1.99h6v14.03L3,19.52L3,5.49h6L9,3.5L3,3.5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h18c1.1,0 2,-0.9 2,-2v-14c0,-1.1 -0.9,-2 -2,-2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_key.xml b/src/android/app/src/main/res/drawable/ic_key.xml
new file mode 100644
index 000000000..a3943634f
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_key.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M21,10h-8.35C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H13l2,2l2,-2l2,2l4,-4.04L21,10zM7,15c-1.65,0 -3,-1.35 -3,-3c0,-1.65 1.35,-3 3,-3s3,1.35 3,3C10,13.65 8.65,15 7,15z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_launcher.xml b/src/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 000000000..3bb60fdfb
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@drawable/ic_icon_bg" />
+ <foreground android:drawable="@drawable/ic_yuzu" />
+ <monochrome android:drawable="@drawable/ic_yuzu" />
+</adaptive-icon>
diff --git a/src/android/app/src/main/res/drawable/ic_log.xml b/src/android/app/src/main/res/drawable/ic_log.xml
new file mode 100644
index 000000000..f55b9ad85
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_log.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M360,720L600,720Q617,720 628.5,708.5Q640,697 640,680Q640,663 628.5,651.5Q617,640 600,640L360,640Q343,640 331.5,651.5Q320,663 320,680Q320,697 331.5,708.5Q343,720 360,720ZM360,560L600,560Q617,560 628.5,548.5Q640,537 640,520Q640,503 628.5,491.5Q617,480 600,480L360,480Q343,480 331.5,491.5Q320,503 320,520Q320,537 331.5,548.5Q343,560 360,560ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L527,80Q543,80 557.5,86Q572,92 583,103L777,297Q788,308 794,322.5Q800,337 800,353L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,320L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L560,360Q543,360 531.5,348.5Q520,337 520,320ZM240,160L240,160L240,320Q240,337 240,348.5Q240,360 240,360L240,360L240,160L240,320Q240,337 240,348.5Q240,360 240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_nfc.xml b/src/android/app/src/main/res/drawable/ic_nfc.xml
new file mode 100644
index 000000000..3dacf798b
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_nfc.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M20,2L4,2c-1.1,0 -2,0.9 -2,2v16c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,20L4,20L4,4h16v16zM18,6h-5c-1.1,0 -2,0.9 -2,2v2.28c-0.6,0.35 -1,0.98 -1,1.72 0,1.1 0.9,2 2,2s2,-0.9 2,-2c0,-0.74 -0.4,-1.38 -1,-1.72L13,8h3v8L8,16L8,8h2L10,6L6,6v12h12L18,6z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_notification.xml b/src/android/app/src/main/res/drawable/ic_notification.xml
new file mode 100644
index 000000000..b413f7585
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_notification.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M7.58,4.08L6.15,2.65C3.75,4.48 2.17,7.3 2.03,10.5h2c0.15,-2.65 1.51,-4.97 3.55,-6.42zM19.97,10.5h2c-0.15,-3.2 -1.73,-6.02 -4.12,-7.85l-1.42,1.43c2.02,1.45 3.39,3.77 3.54,6.42zM18,11c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2v-5zM12,22c0.14,0 0.27,-0.01 0.4,-0.04 0.65,-0.14 1.18,-0.58 1.44,-1.18 0.1,-0.24 0.15,-0.5 0.15,-0.78h-4c0.01,1.1 0.9,2 2.01,2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_options.xml b/src/android/app/src/main/res/drawable/ic_options.xml
new file mode 100644
index 000000000..91d52f1b8
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_options.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M22.7,19l-9.1,-9.1c0.9,-2.3 0.4,-5 -1.5,-6.9 -2,-2 -5,-2.4 -7.4,-1.3L9,6 6,9 1.6,4.7C0.4,7.1 0.9,10.1 2.9,12.1c1.9,1.9 4.6,2.4 6.9,1.5l9.1,9.1c0.4,0.4 1,0.4 1.4,0l2.3,-2.3c0.5,-0.4 0.5,-1.1 0.1,-1.4z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_palette.xml b/src/android/app/src/main/res/drawable/ic_palette.xml
new file mode 100644
index 000000000..43daec1ff
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_palette.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,2C6.49,2 2,6.49 2,12s4.49,10 10,10c1.38,0 2.5,-1.12 2.5,-2.5c0,-0.61 -0.23,-1.2 -0.64,-1.67c-0.08,-0.1 -0.13,-0.21 -0.13,-0.33c0,-0.28 0.22,-0.5 0.5,-0.5H16c3.31,0 6,-2.69 6,-6C22,6.04 17.51,2 12,2zM17.5,13c-0.83,0 -1.5,-0.67 -1.5,-1.5c0,-0.83 0.67,-1.5 1.5,-1.5s1.5,0.67 1.5,1.5C19,12.33 18.33,13 17.5,13zM14.5,9C13.67,9 13,8.33 13,7.5C13,6.67 13.67,6 14.5,6S16,6.67 16,7.5C16,8.33 15.33,9 14.5,9zM5,11.5C5,10.67 5.67,10 6.5,10S8,10.67 8,11.5C8,12.33 7.33,13 6.5,13S5,12.33 5,11.5zM11,7.5C11,8.33 10.33,9 9.5,9S8,8.33 8,7.5C8,6.67 8.67,6 9.5,6S11,6.67 11,7.5z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_pause.xml b/src/android/app/src/main/res/drawable/ic_pause.xml
new file mode 100644
index 000000000..adb3ababc
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_pause.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_play.xml b/src/android/app/src/main/res/drawable/ic_play.xml
new file mode 100644
index 000000000..7f01dc599
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_play.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M8,5v14l11,-7z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_save.xml b/src/android/app/src/main/res/drawable/ic_save.xml
new file mode 100644
index 000000000..a9af3d9cf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_save.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="960"
+ android:viewportHeight="960"
+ android:tint="?attr/colorControlNormal">
+ <path
+ android:fillColor="@android:color/white"
+ android:pathData="M200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L647,120Q663,120 677.5,126Q692,132 703,143L817,257Q828,268 834,282.5Q840,297 840,313L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM280,400L560,400Q577,400 588.5,388.5Q600,377 600,360L600,280Q600,263 588.5,251.5Q577,240 560,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,377 251.5,388.5Q263,400 280,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_search.xml b/src/android/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 000000000..bb0726851
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_settings.xml b/src/android/app/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 000000000..e527f85fc
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportHeight="24"
+ android:viewportWidth="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_settings_outline.xml b/src/android/app/src/main/res/drawable/ic_settings_outline.xml
new file mode 100644
index 000000000..13b2745bf
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_settings_outline.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.12,-0.64l-2,-3.46c-0.09,-0.16 -0.26,-0.25 -0.44,-0.25 -0.06,0 -0.12,0.01 -0.17,0.03l-2.49,1c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.49,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1c-0.06,-0.02 -0.12,-0.03 -0.18,-0.03 -0.17,0 -0.34,0.09 -0.43,0.25l-2,3.46c-0.13,0.22 -0.07,0.49 0.12,0.64l2.11,1.65c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.12,0.64l2,3.46c0.09,0.16 0.26,0.25 0.44,0.25 0.06,0 0.12,-0.01 0.17,-0.03l2.49,-1c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.03,0.24 0.24,0.42 0.49,0.42h4c0.25,0 0.46,-0.18 0.49,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1c0.06,0.02 0.12,0.03 0.18,0.03 0.17,0 0.34,-0.09 0.43,-0.25l2,-3.46c0.12,-0.22 0.07,-0.49 -0.12,-0.64l-2.11,-1.65zM17.45,11.27c0.04,0.31 0.05,0.52 0.05,0.73 0,0.21 -0.02,0.43 -0.05,0.73l-0.14,1.13 0.89,0.7 1.08,0.84 -0.7,1.21 -1.27,-0.51 -1.04,-0.42 -0.9,0.68c-0.43,0.32 -0.84,0.56 -1.25,0.73l-1.06,0.43 -0.16,1.13 -0.2,1.35h-1.4l-0.19,-1.35 -0.16,-1.13 -1.06,-0.43c-0.43,-0.18 -0.83,-0.41 -1.23,-0.71l-0.91,-0.7 -1.06,0.43 -1.27,0.51 -0.7,-1.21 1.08,-0.84 0.89,-0.7 -0.14,-1.13c-0.03,-0.31 -0.05,-0.54 -0.05,-0.74s0.02,-0.43 0.05,-0.73l0.14,-1.13 -0.89,-0.7 -1.08,-0.84 0.7,-1.21 1.27,0.51 1.04,0.42 0.9,-0.68c0.43,-0.32 0.84,-0.56 1.25,-0.73l1.06,-0.43 0.16,-1.13 0.2,-1.35h1.39l0.19,1.35 0.16,1.13 1.06,0.43c0.43,0.18 0.83,0.41 1.23,0.71l0.91,0.7 1.06,-0.43 1.27,-0.51 0.7,1.21 -1.07,0.85 -0.89,0.7 0.14,1.13zM12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_unlock.xml b/src/android/app/src/main/res/drawable/ic_unlock.xml
new file mode 100644
index 000000000..40952cbc5
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_unlock.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_website.xml b/src/android/app/src/main/res/drawable/ic_website.xml
new file mode 100644
index 000000000..f35b84a7c
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_website.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="?attr/colorControlNormal"
+ android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,19.93c-3.95,-0.49 -7,-3.85 -7,-7.93 0,-0.62 0.08,-1.21 0.21,-1.79L9,15v1c0,1.1 0.9,2 2,2v1.93zM17.9,17.39c-0.26,-0.81 -1,-1.39 -1.9,-1.39h-1v-3c0,-0.55 -0.45,-1 -1,-1L8,12v-2h2c0.55,0 1,-0.45 1,-1L11,7h2c1.1,0 2,-0.9 2,-2v-0.41c2.93,1.19 5,4.06 5,7.41 0,2.08 -0.8,3.97 -2.1,5.39z" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_yuzu.xml b/src/android/app/src/main/res/drawable/ic_yuzu.xml
new file mode 100644
index 000000000..5e2a8efd0
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_yuzu.xml
@@ -0,0 +1,22 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="200dp"
+ android:height="200dp"
+ android:viewportWidth="500"
+ android:viewportHeight="500">
+ <path
+ android:fillColor="#FF3C28"
+ android:fillType="nonZero"
+ android:pathData="M262.66,175.11L262.66,375.05C318.54,375.05 363.85,330.29 363.85,275.08C363.85,219.87 318.54,175.11 262.66,175.11M282.43,197.01C318.67,206 344.09,238.19 344.09,275.11C344.09,312.03 318.67,344.22 282.43,353.2L282.43,197.01"
+ android:strokeWidth="1.46"
+ android:strokeColor="#00000000"
+ android:strokeLineCap="butt"
+ android:strokeLineJoin="miter" />
+ <path
+ android:fillColor="#0AB9E6"
+ android:fillType="nonZero"
+ android:pathData="M237.31,125.11C181.43,125.11 136.12,169.87 136.12,225.08C136.12,280.29 181.43,325.05 237.31,325.05ZM217.57,147.01L217.57,303.2C189.11,296.16 166.67,274.54 158.84,246.6C151.01,218.65 159,188.71 179.75,168.21C190.16,157.86 203.24,150.53 217.57,147.01"
+ android:strokeWidth="1.46"
+ android:strokeColor="#00000000"
+ android:strokeLineCap="butt"
+ android:strokeLineJoin="miter" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_full.xml b/src/android/app/src/main/res/drawable/ic_yuzu_full.xml
new file mode 100644
index 000000000..04e458400
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_yuzu_full.xml
@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="155.3dp"
+ android:height="172.55dp"
+ android:viewportWidth="155.3"
+ android:viewportHeight="172.55">
+ <path
+ android:fillColor="#FF3C28"
+ android:pathData="M86.28,34.51v138a69,69 0,0 0,0 -138M99.76,49.63a55.57,55.57 0,0 1,0 107.8V49.63" />
+ <path
+ android:fillColor="#0AB9E6"
+ android:pathData="M69,0a69,69 0,0 0,0 138ZM55.54,15.12v107.8A55.55,55.55 0,0 1,29.75 29.75,55.1 55.1,0 0,1 55.54,15.12" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/ic_yuzu_title.xml b/src/android/app/src/main/res/drawable/ic_yuzu_title.xml
new file mode 100644
index 000000000..b733e5248
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/ic_yuzu_title.xml
@@ -0,0 +1,24 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="340.97dp"
+ android:height="389.85dp"
+ android:viewportWidth="340.97"
+ android:viewportHeight="389.85">
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M341,268.68v73c0,14.5 -2.24,25.24 -6.83,32.82 -5.92,10.15 -16.21,15.32 -30.54,15.32S279,384.61 273,374.27c-4.56,-7.64 -6.8,-18.42 -6.8,-32.92V268.68a4.52,4.52 0,0 1,4.51 -4.51H273a4.5,4.5 0,0 1,4.5 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.52,4.52 0,0 1,4.52 -4.51h2.27A4.5,4.5 0,0 1,341 268.68Z" />
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M246.49,389.85H178.6c-2.35,0 -4.72,-1.88 -4.72,-6.08a8.28,8.28 0,0 1,1.33 -4.48l60.33,-104.47H186a4.51,4.51 0,0 1,-4.51 -4.51v-1.58a4.51,4.51 0,0 1,4.48 -4.51h0.8c58.69,-0.11 59.12,0 59.67,0.07a5.19,5.19 0,0 1,4 5.8,8.69 8.69,0 0,1 -1.33,3.76l-60.6,104.77h58a4.51,4.51 0,0 1,4.51 4.51v2.21A4.51,4.51 0,0 1,246.49 389.85Z" />
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M73.6,268.68v82.06c0,26 -11.8,38.44 -37.12,39.09h-0.12a4.51,4.51 0,0 1,-4.51 -4.51V383a4.51,4.51 0,0 1,4.48 -4.5c18.49,-0.15 26,-8.23 26,-27.9v-2.37A32.34,32.34 0,0 1,59 351.46c-6.39,5.5 -14.5,8.29 -24.07,8.29C12.09,359.75 0,347.34 0,323.86V268.68a4.52,4.52 0,0 1,4.51 -4.51H6.73a4.52,4.52 0,0 1,4.5 4.51v55c0,7.6 1.82,14.22 5,18.18 3.57,4.56 9.17,6.49 18.75,6.49 10.13,0 17.32,-3.76 22,-11.5 3.61,-5.92 5.43,-13.66 5.43,-23V268.68a4.52,4.52 0,0 1,4.51 -4.51h2.22A4.52,4.52 0,0 1,73.6 268.68Z" />
+ <path
+ android:fillColor="?attr/colorOnSurface"
+ android:pathData="M163.27,268.68v73c0,14.5 -2.24,25.24 -6.84,32.82 -5.92,10.15 -16.2,15.32 -30.53,15.32s-24.62,-5.23 -30.58,-15.57c-4.56,-7.64 -6.79,-18.42 -6.79,-32.92V268.68A4.51,4.51 0,0 1,93 264.17h2.28a4.51,4.51 0,0 1,4.51 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.51,4.51 0,0 1,4.51 -4.51h2.27A4.51,4.51 0,0 1,163.27 268.68Z" />
+ <path
+ android:fillColor="#ff3c28"
+ android:pathData="M181.2,42.83V214.17a85.67,85.67 0,0 0,0 -171.34M197.93,61.6a69,69 0,0 1,0 133.8V61.6" />
+ <path
+ android:fillColor="#0ab9e6"
+ android:pathData="M159.78,0a85.67,85.67 0,1 0,0 171.33ZM143.05,18.77v133.8A69,69 0,0 1,111 36.92a68.47,68.47 0,0 1,32 -18.15" />
+</vector>
diff --git a/src/android/app/src/main/res/drawable/joystick.xml b/src/android/app/src/main/res/drawable/joystick.xml
new file mode 100644
index 000000000..bdd071212
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/joystick.xml
@@ -0,0 +1,45 @@
+<vector android:alpha="0.6" android:height="161.61dp"
+ android:viewportHeight="161.61" android:viewportWidth="161.61"
+ android:width="161.61dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.8"
+ android:pathData="M91.23,0.68a80.8,80.8 0,1 0,69.69 90.55A80.81,80.81 0,0 0,91.23 0.68ZM80.8,150.68A69.84,69.84 0,1 1,150.64 80.8,69.92 69.92,0 0,1 80.8,150.64Z" android:strokeAlpha="0.8">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="68.25" android:centerY="57.75"
+ android:gradientRadius="122.17" android:type="radial">
+ <item android:color="#FFD9D9D9" android:offset="0.44"/>
+ <item android:color="#FF141414" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.8"
+ android:pathData="M80.8,80.8m-67.05,0a67.05,67.05 0,1 1,134.1 0a67.05,67.05 0,1 1,-134.1 0" android:strokeAlpha="0.8">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="80.49" android:centerY="60.19"
+ android:gradientRadius="88.23" android:type="radial">
+ <item android:color="#FFBABABA" android:offset="0.15"/>
+ <item android:color="#FF9E9E9E" android:offset="0.46"/>
+ <item android:color="#FF868686" android:offset="0.63"/>
+ <item android:color="#FF575757" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.8"
+ android:pathData="M80.8,150.64A69.84,69.84 0,1 1,150.64 80.8,69.92 69.92,0 0,1 80.8,150.64ZM80.8,13.76a67,67 0,1 0,67.05 67A67.11,67.11 0,0 0,80.8 13.76Z" android:strokeAlpha="0.8">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="80.8" android:centerY="80.8"
+ android:gradientRadius="97.63" android:type="radial">
+ <item android:color="#FFC2C2C3" android:offset="0.04"/>
+ <item android:color="#FFC0C0C1" android:offset="0.35"/>
+ <item android:color="#FFB9B9BA" android:offset="0.47"/>
+ <item android:color="#FFADADAE" android:offset="0.56"/>
+ <item android:color="#FF9C9C9D" android:offset="0.63"/>
+ <item android:color="#FF868687" android:offset="0.69"/>
+ <item android:color="#FF6A6A6B" android:offset="0.74"/>
+ <item android:color="#FF4A4A4A" android:offset="0.79"/>
+ <item android:color="#FF252525" android:offset="0.83"/>
+ <item android:color="#FF000000" android:offset="0.87"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/joystick_depressed.xml b/src/android/app/src/main/res/drawable/joystick_depressed.xml
new file mode 100644
index 000000000..ad51d73ce
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/joystick_depressed.xml
@@ -0,0 +1,10 @@
+<vector android:alpha="0.6" android:height="161.73dp"
+ android:viewportHeight="161.73" android:viewportWidth="161.73"
+ android:width="161.73dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#FF000000"
+ android:pathData="M91.3,0.68A80.86,80.86 0,1 0,161.05 91.3,80.87 80.87,0 0,0 91.3,0.68ZM80.87,150.76a69.9,69.9 0,1 1,69.89 -69.89A70,70 0,0 1,80.87 150.76Z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.5" android:fillColor="#FF000000"
+ android:pathData="M80.87,80.87m-67.1,0a67.1,67.1 0,1 1,134.2 0a67.1,67.1 0,1 1,-134.2 0" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M80.87,150.76a69.9,69.9 0,1 1,69.89 -69.89A70,70 0,0 1,80.87 150.76ZM80.87,13.76A67.1,67.1 0,1 0,148 80.87,67.17 67.17,0 0,0 80.87,13.77Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/joystick_range.xml b/src/android/app/src/main/res/drawable/joystick_range.xml
new file mode 100644
index 000000000..f6282b5c8
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/joystick_range.xml
@@ -0,0 +1,38 @@
+<vector android:alpha="0.6" android:height="265.64dp"
+ android:viewportHeight="265.64" android:viewportWidth="265.64"
+ android:width="265.64dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.8"
+ android:pathData="M132.82,132.82m-113.12,0a113.12,113.12 0,1 1,226.24 0a113.12,113.12 0,1 1,-226.24 0" android:strokeAlpha="0.8">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="132.82" android:centerY="132.82"
+ android:gradientRadius="195.71" android:type="radial">
+ <item android:color="#00000000" android:offset="0"/>
+ <item android:color="#14161515" android:offset="0.27"/>
+ <item android:color="#30333031" android:offset="0.42"/>
+ <item android:color="#42393738" android:offset="0.45"/>
+ <item android:color="#754B494A" android:offset="0.51"/>
+ <item android:color="#C6676666" android:offset="0.59"/>
+ <item android:color="#FF7A7A7A" android:offset="0.63"/>
+ <item android:color="#FF787878" android:offset="0.99"/>
+ <item android:color="#FF787878" android:offset="0.99"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.6"
+ android:pathData="m18.72,64.82a132.8,132.8 0,1 0,182.06 -46.1,132.8 132.8,0 0,0 -182.06,46.1zM229.98,190.7a113.12,113.12 0,1 1,-39.28 -155.08,113.12 113.12,0 0,1 39.28,155.08z" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:centerX="132.82" android:centerY="132.7"
+ android:gradientRadius="141.24" android:type="radial">
+ <item android:color="#FF969696" android:offset="0"/>
+ <item android:color="#FF949494" android:offset="0.8"/>
+ <item android:color="#FF8D8D8D" android:offset="0.84"/>
+ <item android:color="#FF828282" android:offset="0.87"/>
+ <item android:color="#FF717171" android:offset="0.9"/>
+ <item android:color="#FF5B5B5B" android:offset="0.94"/>
+ <item android:color="#FF404040" android:offset="0.98"/>
+ <item android:color="#FF303030" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/l_shoulder.xml b/src/android/app/src/main/res/drawable/l_shoulder.xml
new file mode 100644
index 000000000..28f9a9950
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/l_shoulder.xml
@@ -0,0 +1,23 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M33.05,0L211.86,0A33.05,33.05 0,0 1,244.91 33.05L244.91,65.53A33.05,33.05 0,0 1,211.86 98.58L33.05,98.58A33.05,33.05 0,0 1,0 65.53L0,33.05A33.05,33.05 0,0 1,33.05 0z" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="244.91" android:endY="49.29"
+ android:startX="0" android:startY="49.29" android:type="linear">
+ <item android:color="#FFC3C4C5" android:offset="0"/>
+ <item android:color="#FFC4C5C5" android:offset="0.05"/>
+ <item android:color="#FFC7C7C7" android:offset="0.47"/>
+ <item android:color="#F7C4C4C4" android:offset="0.54"/>
+ <item android:color="#E5BABABA" android:offset="0.65"/>
+ <item android:color="#C6ABABAB" android:offset="0.77"/>
+ <item android:color="#9E969696" android:offset="0.91"/>
+ <item android:color="#7F878787" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M106.15,20h7.57V72.24h25v6.35h-32.6Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml b/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml
new file mode 100644
index 000000000..2f9a1fd7e
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/l_shoulder_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M33.05,0L211.86,0A33.05,33.05 0,0 1,244.91 33.05L244.91,65.53A33.05,33.05 0,0 1,211.86 98.58L33.05,98.58A33.05,33.05 0,0 1,0 65.53L0,33.05A33.05,33.05 0,0 1,33.05 0z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M106.15,20h7.57V72.24h25v6.35h-32.6Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/premium_background.xml b/src/android/app/src/main/res/drawable/premium_background.xml
new file mode 100644
index 000000000..c9c41ddbe
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/premium_background.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+ <gradient
+ android:type="linear"
+ android:angle="45"
+ android:startColor="@color/yuzu_ea_background_start"
+ android:endColor="@color/yuzu_ea_background_end" />
+ <corners android:radius="12dp" />
+</shape>
diff --git a/src/android/app/src/main/res/drawable/r_shoulder.xml b/src/android/app/src/main/res/drawable/r_shoulder.xml
new file mode 100644
index 000000000..97731cad2
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/r_shoulder.xml
@@ -0,0 +1,23 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M211.86,98.58L33.05,98.58A33.05,33.05 0,0 1,0 65.53L0,33.05A33.05,33.05 0,0 1,33.05 0L211.86,0A33.05,33.05 0,0 1,244.91 33.05L244.91,65.53A33.05,33.05 0,0 1,211.86 98.58z" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="0" android:endY="49.29"
+ android:startX="244.91" android:startY="49.29" android:type="linear">
+ <item android:color="#FFC3C4C5" android:offset="0"/>
+ <item android:color="#FFC4C5C5" android:offset="0.05"/>
+ <item android:color="#FFC7C7C7" android:offset="0.47"/>
+ <item android:color="#F7C4C4C4" android:offset="0.54"/>
+ <item android:color="#E5BABABA" android:offset="0.65"/>
+ <item android:color="#C6ABABAB" android:offset="0.77"/>
+ <item android:color="#9E969696" android:offset="0.91"/>
+ <item android:color="#7F878787" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M103.37,21a78.13,78.13 0,0 1,14.52 -1.22c8.08,0 13.3,1.48 17,4.78a14.59,14.59 0,0 1,4.61 11.13c0,7.73 -4.87,12.86 -11,15v0.26c4.52,1.56 7.21,5.74 8.6,11.82 1.92,8.17 3.31,13.82 4.52,16.08h-7.82c-1,-1.65 -2.26,-6.69 -3.91,-14 -1.74,-8.09 -4.87,-11.13 -11.74,-11.39h-7.12L111.03,78.8h-7.57ZM110.94,47.68h7.73c8.09,0 13.22,-4.43 13.22,-11.12 0,-7.57 -5.48,-10.87 -13.48,-11a30.82,30.82 0,0 0,-7.47 0.7Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml b/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml
new file mode 100644
index 000000000..e3aa46aa1
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/r_shoulder_depressed.xml
@@ -0,0 +1,8 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M211.86,98.58L33.05,98.58A33.05,33.05 0,0 1,0 65.53L0,33.05A33.05,33.05 0,0 1,33.05 0L211.86,0A33.05,33.05 0,0 1,244.91 33.05L244.91,65.53A33.05,33.05 0,0 1,211.86 98.58z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M103.37,21a78.13,78.13 0,0 1,14.52 -1.22c8.08,0 13.3,1.48 17,4.78a14.59,14.59 0,0 1,4.61 11.13c0,7.73 -4.87,12.86 -11,15v0.26c4.52,1.56 7.21,5.74 8.6,11.82 1.92,8.17 3.31,13.82 4.52,16.08h-7.82c-1,-1.65 -2.26,-6.69 -3.91,-14 -1.74,-8.09 -4.87,-11.13 -11.74,-11.39h-7.12L111.03,78.8h-7.57ZM110.94,47.68h7.73c8.09,0 13.22,-4.43 13.22,-11.12 0,-7.57 -5.48,-10.87 -13.48,-11a30.82,30.82 0,0 0,-7.47 0.7Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/selector_cartridge.xml b/src/android/app/src/main/res/drawable/selector_cartridge.xml
new file mode 100644
index 000000000..85c918dae
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/selector_cartridge.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@drawable/ic_cartridge_outline" android:state_checked="false"/>
+ <item android:drawable="@drawable/ic_cartridge" android:state_checked="true"/>
+</selector>
diff --git a/src/android/app/src/main/res/drawable/selector_settings.xml b/src/android/app/src/main/res/drawable/selector_settings.xml
new file mode 100644
index 000000000..23748feb0
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/selector_settings.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<selector xmlns:android="http://schemas.android.com/apk/res/android">
+ <item android:drawable="@drawable/ic_settings_outline" android:state_checked="false"/>
+ <item android:drawable="@drawable/ic_settings" android:state_checked="true"/>
+</selector>
diff --git a/src/android/app/src/main/res/drawable/zl_trigger.xml b/src/android/app/src/main/res/drawable/zl_trigger.xml
new file mode 100644
index 000000000..436461c3b
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/zl_trigger.xml
@@ -0,0 +1,25 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M84.62,0h145a15.32,15.32 0,0 1,15.32 15.32V67a31.54,31.54 0,0 1,-31.54 31.54H14a14,14 0,0 1,-14 -14v0A84.62,84.62 0,0 1,84.62 0Z" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="244.91" android:endY="49.29"
+ android:startX="0" android:startY="49.29" android:type="linear">
+ <item android:color="#FFC3C4C5" android:offset="0"/>
+ <item android:color="#FFC4C5C5" android:offset="0.05"/>
+ <item android:color="#FFC7C7C7" android:offset="0.47"/>
+ <item android:color="#F7C4C4C4" android:offset="0.54"/>
+ <item android:color="#E5BABABA" android:offset="0.65"/>
+ <item android:color="#C6ABABAB" android:offset="0.77"/>
+ <item android:color="#9E969696" android:offset="0.91"/>
+ <item android:color="#7F878787" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M80.12,74.15 L112.63,26.6v-0.26H82.9V20h39.56v4.6L90.12,72v0.26h32.77v6.35H80.12Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M132.19,20h7.56V72.24h25v6.35h-32.6Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml b/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml
new file mode 100644
index 000000000..00393c04d
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/zl_trigger_depressed.xml
@@ -0,0 +1,10 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M84.62,0h145a15.32,15.32 0,0 1,15.32 15.32V67a31.54,31.54 0,0 1,-31.54 31.54H14a14,14 0,0 1,-14 -14v0A84.62,84.62 0,0 1,84.62 0Z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M80.12,74.15 L112.63,26.6v-0.26H82.9V20h39.56v4.6L90.12,72v0.26h32.77v6.35H80.12Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M132.19,20h7.56V72.24h25v6.35h-32.6Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/zr_trigger.xml b/src/android/app/src/main/res/drawable/zr_trigger.xml
new file mode 100644
index 000000000..2b3a92184
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/zr_trigger.xml
@@ -0,0 +1,25 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp"
+ xmlns:aapt="http://schemas.android.com/aapt" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.6"
+ android:pathData="M230.91,98.58l-199.4,-0a31.54,31.54 135,0 1,-31.54 -31.54L-0.03,15.31a15.32,15.32 0,0 1,15.32 -15.32l145,-0A84.62,84.62 0,0 1,244.91 84.58l-0,-0A14,14 0,0 1,230.91 98.58Z" android:strokeAlpha="0.6">
+ <aapt:attr name="android:fillColor">
+ <gradient android:endX="0" android:endY="49.29"
+ android:startX="244.91" android:startY="49.29" android:type="linear">
+ <item android:color="#FFC3C4C5" android:offset="0"/>
+ <item android:color="#FFC4C5C5" android:offset="0.05"/>
+ <item android:color="#FFC7C7C7" android:offset="0.47"/>
+ <item android:color="#F7C4C4C4" android:offset="0.54"/>
+ <item android:color="#E5BABABA" android:offset="0.65"/>
+ <item android:color="#C6ABABAB" android:offset="0.77"/>
+ <item android:color="#9E969696" android:offset="0.91"/>
+ <item android:color="#7F878787" android:offset="1"/>
+ </gradient>
+ </aapt:attr>
+ </path>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M77.34,74.37l32.51,-47.55v-0.26H80.12V20.21h39.55v4.61L87.34,72.2v0.26h32.77V78.8H77.34Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.75" android:fillColor="#FF000000"
+ android:pathData="M129.41,21a78,78 0,0 1,14.51 -1.22c8.09,0 13.3,1.48 17,4.78a14.62,14.62 0,0 1,4.6 11.13c0,7.73 -4.87,12.86 -11,15v0.26c4.52,1.56 7.22,5.74 8.61,11.82 1.91,8.17 3.3,13.82 4.52,16.08h-7.82c-1,-1.65 -2.26,-6.69 -3.92,-14C154.1,56.72 151,53.68 144.1,53.42H137V78.8h-7.56ZM137,47.68h7.74c8.08,0 13.21,-4.43 13.21,-11.12 0,-7.57 -5.48,-10.87 -13.47,-11a30.92,30.92 0,0 0,-7.48 0.7Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml b/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml
new file mode 100644
index 000000000..8a9ee5036
--- /dev/null
+++ b/src/android/app/src/main/res/drawable/zr_trigger_depressed.xml
@@ -0,0 +1,10 @@
+<vector android:alpha="0.6" android:height="98.58dp"
+ android:viewportHeight="98.58" android:viewportWidth="244.91"
+ android:width="244.91dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillAlpha="0.5" android:fillColor="#151515"
+ android:pathData="M230.91,98.58l-199.4,-0a31.54,31.54 135,0 1,-31.54 -31.54L-0.03,15.31a15.32,15.32 0,0 1,15.32 -15.32l145,-0A84.62,84.62 0,0 1,244.91 84.58l-0,-0A14,14 0,0 1,230.91 98.58Z" android:strokeAlpha="0.5"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M77.34,74.37l32.51,-47.55v-0.26H80.12V20.21h39.55v4.61L87.34,72.2v0.26h32.77V78.8H77.34Z" android:strokeAlpha="0.75"/>
+ <path android:fillAlpha="0.75" android:fillColor="#fff"
+ android:pathData="M129.41,21a78,78 0,0 1,14.51 -1.22c8.09,0 13.3,1.48 17,4.78a14.62,14.62 0,0 1,4.6 11.13c0,7.73 -4.87,12.86 -11,15v0.26c4.52,1.56 7.22,5.74 8.61,11.82 1.91,8.17 3.3,13.82 4.52,16.08h-7.82c-1,-1.65 -2.26,-6.69 -3.92,-14C154.1,56.72 151,53.68 144.1,53.42H137V78.8h-7.56ZM137,47.68h7.74c8.08,0 13.21,-4.43 13.21,-11.12 0,-7.57 -5.48,-10.87 -13.47,-11a30.92,30.92 0,0 0,-7.48 0.7Z" android:strokeAlpha="0.75"/>
+</vector>
diff --git a/src/android/app/src/main/res/layout-w600dp/activity_main.xml b/src/android/app/src/main/res/layout-w600dp/activity_main.xml
new file mode 100644
index 000000000..74bee872e
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/activity_main.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/coordinator_main"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/fragment_container"
+ android:name="androidx.navigation.fragment.NavHostFragment"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:defaultNavHost="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:navGraph="@navigation/home_navigation"
+ tools:layout="@layout/fragment_games" />
+
+ <com.google.android.material.navigationrail.NavigationRailView
+ android:id="@+id/navigation_view"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:labelVisibilityMode="selected"
+ app:menu="@menu/menu_navigation"
+ tools:visibility="visible" />
+
+ <View
+ android:id="@+id/status_bar_shade"
+ android:layout_width="0dp"
+ android:layout_height="1px"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <View
+ android:id="@+id/navigation_bar_shade"
+ android:layout_width="0dp"
+ android:layout_height="1px"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml
new file mode 100644
index 000000000..cbe631d88
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/fragment_setup.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/setup_root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager2"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <com.google.android.material.button.MaterialButton
+ style="@style/Widget.Material3.Button.TextButton"
+ android:id="@+id/button_next"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/next"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_back"
+ style="@style/Widget.Material3.Button.TextButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/back"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout-w600dp/page_setup.xml b/src/android/app/src/main/res/layout-w600dp/page_setup.xml
new file mode 100644
index 000000000..e1c26b2f8
--- /dev/null
+++ b/src/android/app/src/main/res/layout-w600dp/page_setup.xml
@@ -0,0 +1,65 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:layout_weight="1"
+ android:gravity="center">
+
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="260dp"
+ android:layout_height="260dp"
+ android:layout_gravity="center" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:orientation="vertical"
+ android:gravity="center">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.DisplaySmall"
+ android:id="@+id/text_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurface"
+ android:textStyle="bold"
+ tools:text="@string/welcome" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:id="@+id/text_description"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:paddingHorizontal="32dp"
+ android:textAlignment="center"
+ android:textSize="26sp"
+ app:lineHeight="40sp"
+ tools:text="@string/welcome_description" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_action"
+ android:layout_width="wrap_content"
+ android:layout_height="56dp"
+ android:layout_marginTop="32dp"
+ android:textSize="20sp"
+ app:iconSize="24sp"
+ app:iconGravity="end"
+ tools:text="Get started" />
+
+ </LinearLayout>
+
+</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml
new file mode 100644
index 000000000..f6360a65b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_emulation.xml
@@ -0,0 +1,13 @@
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/frame_content"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:keepScreenOn="true">
+
+ <FrameLayout
+ android:id="@+id/frame_emulation_fragment"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+</FrameLayout>
diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 000000000..ad426457f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/coordinator_main"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <androidx.fragment.app.FragmentContainerView
+ android:id="@+id/fragment_container"
+ android:name="androidx.navigation.fragment.NavHostFragment"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:defaultNavHost="true"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:navGraph="@navigation/home_navigation"
+ tools:layout="@layout/fragment_games" />
+
+ <com.google.android.material.bottomnavigation.BottomNavigationView
+ android:id="@+id/navigation_view"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:menu="@menu/menu_navigation"
+ app:labelVisibilityMode="selected"
+ tools:visibility="visible" />
+
+ <View
+ android:id="@+id/status_bar_shade"
+ android:layout_width="0dp"
+ android:layout_height="1px"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <View
+ android:id="@+id/navigation_bar_shade"
+ android:layout_width="0dp"
+ android:layout_height="1px"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 000000000..14ae83b04
--- /dev/null
+++ b/src/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+ android:id="@+id/coordinator_main"
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_settings"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true"
+ app:elevation="0dp">
+
+ <com.google.android.material.appbar.CollapsingToolbarLayout
+ style="?attr/collapsingToolbarLayoutMediumStyle"
+ android:id="@+id/toolbar_settings_layout"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
+ app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_settings"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:layout_collapseMode="pin" />
+
+ </com.google.android.material.appbar.CollapsingToolbarLayout>
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <FrameLayout
+ android:id="@+id/frame_content"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginHorizontal="12dp"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+ <View
+ android:id="@+id/navigation_bar_shade"
+ android:layout_width="match_parent"
+ android:layout_height="1px"
+ android:background="@android:color/transparent"
+ android:clickable="false"
+ android:focusable="false"
+ android:layout_gravity="bottom|center_horizontal" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml
new file mode 100644
index 000000000..1f5de219b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_game.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.card.MaterialCardView
+ style="?attr/materialCardViewElevatedStyle"
+ android:id="@+id/card_game"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:background="?attr/selectableItemBackground"
+ android:clickable="true"
+ android:clipToPadding="false"
+ android:focusable="true"
+ android:transitionName="card_game"
+ android:layout_gravity="center"
+ app:cardElevation="0dp"
+ app:cardCornerRadius="12dp">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:padding="6dp">
+
+ <com.google.android.material.card.MaterialCardView
+ style="?attr/materialCardViewElevatedStyle"
+ android:id="@+id/card_game_art"
+ android:layout_width="150dp"
+ android:layout_height="150dp"
+ app:cardCornerRadius="4dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <ImageView
+ android:id="@+id/image_game_screen"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ tools:src="@drawable/default_icon" />
+
+ </com.google.android.material.card.MaterialCardView>
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:id="@+id/text_game_title"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:textAlignment="center"
+ android:textSize="14sp"
+ android:singleLine="true"
+ android:marqueeRepeatLimit="marquee_forever"
+ android:ellipsize="none"
+ android:requiresFadingEdge="horizontal"
+ app:layout_constraintEnd_toEndOf="@+id/card_game_art"
+ app:layout_constraintStart_toStartOf="@+id/card_game_art"
+ app:layout_constraintTop_toBottomOf="@+id/card_game_art"
+ tools:text="The Legend of Zelda: Skyward Sword" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+</FrameLayout>
diff --git a/src/android/app/src/main/res/layout/card_home_option.xml b/src/android/app/src/main/res/layout/card_home_option.xml
new file mode 100644
index 000000000..dc289db17
--- /dev/null
+++ b/src/android/app/src/main/res/layout/card_home_option.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ style="?attr/materialCardViewFilledStyle"
+ android:id="@+id/option_card"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginVertical="12dp"
+ android:layout_marginHorizontal="16dp"
+ android:background="?attr/selectableItemBackground"
+ android:backgroundTint="?attr/colorSurfaceVariant"
+ android:clickable="true"
+ android:focusable="true">
+
+ <LinearLayout
+ android:id="@+id/option_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <ImageView
+ android:id="@+id/option_icon"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_marginStart="24dp"
+ android:layout_gravity="center_vertical"
+ app:tint="?attr/colorOnSurface" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginVertical="10dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:id="@+id/option_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAlignment="viewStart"
+ android:textStyle="bold"
+ android:textSize="16sp"
+ tools:text="@string/install_prod_keys" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:id="@+id/option_description"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textAlignment="viewStart"
+ android:textSize="14sp"
+ android:layout_marginTop="5dp"
+ tools:text="@string/install_prod_keys_description" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+</com.google.android.material.card.MaterialCardView>
diff --git a/src/android/app/src/main/res/layout/dialog_edit_text.xml b/src/android/app/src/main/res/layout/dialog_edit_text.xml
new file mode 100644
index 000000000..58b905d71
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_edit_text.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <com.google.android.material.textfield.TextInputLayout
+ android:id="@+id/edit_text_layout"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_margin="24dp"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <com.google.android.material.textfield.TextInputEditText
+ android:id="@+id/edit_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:inputType="none" />
+
+ </com.google.android.material.textfield.TextInputLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/dialog_license.xml b/src/android/app/src/main/res/layout/dialog_license.xml
new file mode 100644
index 000000000..866857562
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_license.xml
@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <androidx.core.widget.NestedScrollView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:layout_marginHorizontal="16dp">
+
+ <com.google.android.material.bottomsheet.BottomSheetDragHandleView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"/>
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.HeadlineLarge"
+ android:id="@+id/text_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ tools:text="@string/license_adreno_tools" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:id="@+id/text_link"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:layout_marginTop="16dp"
+ android:autoLink="all"
+ tools:text="@string/license_adreno_tools_link" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:id="@+id/text_copyright"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:layout_marginTop="16dp"
+ android:textStyle="bold"
+ tools:text="@string/license_adreno_tools_copyright" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:id="@+id/text_license"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:layout_marginVertical="16dp"
+ android:autoLink="all"
+ tools:text="@string/license_adreno_tools_text" />
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/dialog_overlay_adjust.xml b/src/android/app/src/main/res/layout/dialog_overlay_adjust.xml
new file mode 100644
index 000000000..59bb983e1
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_overlay_adjust.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <TextView
+ android:id="@+id/input_scale_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/emulation_control_scale"
+ android:textAlignment="viewStart"
+ android:textSize="16sp"
+ app:layout_constraintStart_toStartOf="@+id/input_scale_slider"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/input_scale_slider"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/input_scale_name" />
+
+ <TextView
+ android:id="@+id/input_scale_value"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="end"
+ app:layout_constraintBottom_toTopOf="@+id/input_scale_slider"
+ app:layout_constraintEnd_toEndOf="@+id/input_scale_slider"
+ tools:text="100%" />
+
+ <TextView
+ android:id="@+id/input_opacity_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:text="@string/emulation_control_opacity"
+ android:textAlignment="viewStart"
+ android:textSize="16sp"
+ app:layout_constraintStart_toStartOf="@+id/input_opacity_slider"
+ app:layout_constraintTop_toBottomOf="@+id/input_scale_slider" />
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/input_opacity_slider"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/input_opacity_name" />
+
+ <TextView
+ android:id="@+id/input_opacity_value"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:gravity="end"
+ app:layout_constraintBottom_toTopOf="@+id/input_opacity_slider"
+ app:layout_constraintEnd_toEndOf="@+id/input_opacity_slider"
+ tools:text="100%" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
new file mode 100644
index 000000000..d17711a65
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:orientation="vertical">
+
+ <com.google.android.material.progressindicator.LinearProgressIndicator
+ android:id="@+id/progress_bar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_margin="24dp"
+ app:trackCornerRadius="4dp" />
+
+ <TextView
+ android:id="@+id/progress_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="24dp"
+ android:layout_marginRight="24dp"
+ android:layout_marginBottom="24dp"
+ android:gravity="end" />
+
+</LinearLayout>
diff --git a/src/android/app/src/main/res/layout/dialog_slider.xml b/src/android/app/src/main/res/layout/dialog_slider.xml
new file mode 100644
index 000000000..8c84cb606
--- /dev/null
+++ b/src/android/app/src/main/res/layout/dialog_slider.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/text_value"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentTop="true"
+ android:layout_centerHorizontal="true"
+ android:layout_marginBottom="@dimen/spacing_medlarge"
+ android:layout_marginTop="@dimen/spacing_medlarge"
+ tools:text="75" />
+
+ <TextView
+ android:id="@+id/text_units"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignTop="@+id/text_value"
+ android:layout_toEndOf="@+id/text_value"
+ tools:text="%" />
+
+ <com.google.android.material.slider.Slider
+ android:id="@+id/slider"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentStart="true"
+ android:layout_below="@+id/text_value"
+ android:layout_marginBottom="@dimen/spacing_medlarge"
+ android:layout_marginLeft="@dimen/spacing_large"
+ android:layout_marginRight="@dimen/spacing_large" />
+
+</RelativeLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_about.xml b/src/android/app/src/main/res/layout/fragment_about.xml
new file mode 100644
index 000000000..3e1d98451
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_about.xml
@@ -0,0 +1,232 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/coordinator_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_about"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_about"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:title="@string/about"
+ app:navigationIcon="@drawable/ic_back" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/scroll_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scrollbars="vertical"
+ android:fadeScrollbars="false"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <LinearLayout
+ android:id="@+id/content_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical">
+
+ <ImageView
+ android:id="@+id/image_logo"
+ android:layout_width="250dp"
+ android:layout_height="250dp"
+ android:layout_marginTop="20dp"
+ android:layout_gravity="center_horizontal"
+ android:src="@drawable/ic_yuzu_title" />
+
+ <com.google.android.material.divider.MaterialDivider
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp"
+ android:layout_marginTop="28dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="16dp"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:textAlignment="viewStart"
+ android:text="@string/about" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginTop="6dp"
+ android:textAlignment="viewStart"
+ android:text="@string/about_app_description" />
+
+ </LinearLayout>
+
+ <com.google.android.material.divider.MaterialDivider
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp" />
+
+ <LinearLayout
+ android:id="@+id/button_contributors"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="16dp"
+ android:background="?attr/selectableItemBackground"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:textAlignment="viewStart"
+ android:text="@string/contributors" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginTop="6dp"
+ android:textAlignment="viewStart"
+ android:text="@string/contributors_description" />
+
+ </LinearLayout>
+
+ <com.google.android.material.divider.MaterialDivider
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp" />
+
+ <LinearLayout
+ android:id="@+id/button_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="16dp"
+ android:background="?attr/selectableItemBackground"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:textAlignment="viewStart"
+ android:text="@string/licenses" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginTop="6dp"
+ android:textAlignment="viewStart"
+ android:text="@string/licenses_description" />
+
+ </LinearLayout>
+
+ <com.google.android.material.divider.MaterialDivider
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp" />
+
+ <LinearLayout
+ android:id="@+id/button_build_hash"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingVertical="16dp"
+ android:paddingHorizontal="16dp"
+ android:background="?attr/selectableItemBackground"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:textAlignment="viewStart"
+ android:text="@string/build" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/text_build_hash"
+ style="@style/TextAppearance.Material3.BodyMedium"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="24dp"
+ android:layout_marginTop="6dp"
+ android:textAlignment="viewStart"
+ tools:text="abc123" />
+
+ </LinearLayout>
+
+ <com.google.android.material.divider.MaterialDivider
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:gravity="center_horizontal"
+ android:layout_marginTop="12dp"
+ android:layout_marginBottom="16dp"
+ android:layout_marginHorizontal="40dp">
+
+ <Button
+ style="?attr/materialIconButtonStyle"
+ android:id="@+id/button_discord"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ app:icon="@drawable/ic_discord"
+ app:iconTint="?attr/colorOnSurface"
+ app:iconSize="24dp"
+ app:iconGravity="textEnd" />
+
+ <Button
+ style="?attr/materialIconButtonStyle"
+ android:id="@+id/button_website"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ app:icon="@drawable/ic_website"
+ app:iconTint="?attr/colorOnSurface"
+ app:iconSize="24dp"
+ app:iconGravity="textEnd" />
+
+ <Button
+ android:id="@+id/button_github"
+ style="?attr/materialIconButtonStyle"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ app:icon="@drawable/ic_github"
+ app:iconTint="?attr/colorOnSurface"
+ app:iconSize="24dp"
+ app:iconGravity="textEnd" />
+
+ </LinearLayout>
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_early_access.xml b/src/android/app/src/main/res/layout/fragment_early_access.xml
new file mode 100644
index 000000000..644b4dd45
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_early_access.xml
@@ -0,0 +1,242 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/coordinator_about"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_ea"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_about"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:navigationIcon="@drawable/ic_back"
+ app:title="@string/early_access" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.core.widget.NestedScrollView
+ android:id="@+id/scroll_ea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ android:paddingBottom="20dp"
+ android:scrollbars="vertical"
+ android:fadeScrollbars="false"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior">
+
+ <LinearLayout
+ android:id="@+id/card_ea"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginVertical="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:background="@drawable/premium_background"
+ android:orientation="vertical">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:layout_marginHorizontal="20dp"
+ android:text="@string/early_access_benefits"
+ android:textAlignment="center"
+ android:textStyle="bold" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/cutting_edge_features"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/early_access_updates"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/no_manual_installation"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/prioritized_support"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/helping_game_preservation"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="32dp"
+ android:layout_marginHorizontal="20dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical"
+ android:src="@drawable/ic_check_circle"
+ app:tint="?attr/colorOnSurface" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="20dp"
+ android:text="@string/our_eternal_gratitude"
+ android:textAlignment="viewStart"
+ android:layout_gravity="start|center_vertical" />
+
+ </LinearLayout>
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/are_you_interested"
+ android:layout_marginTop="80dp"
+ android:layout_marginHorizontal="20dp"
+ android:textStyle="bold"
+ android:textAlignment="center" />
+
+ <com.google.android.material.card.MaterialCardView
+ style="?attr/materialCardViewFilledStyle"
+ android:id="@+id/get_early_access_button"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="16dp"
+ android:layout_marginHorizontal="20dp"
+ android:layout_marginBottom="28dp"
+ android:background="?attr/selectableItemBackground"
+ android:backgroundTint="@android:color/black">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/get_early_access"
+ android:layout_marginHorizontal="20dp"
+ android:layout_marginVertical="8dp"
+ android:textColor="@android:color/white"
+ android:textStyle="bold"
+ android:textAlignment="center" />
+
+ </com.google.android.material.card.MaterialCardView>
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_emulation.xml b/src/android/app/src/main/res/layout/fragment_emulation.xml
new file mode 100644
index 000000000..09b789b6b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_emulation.xml
@@ -0,0 +1,70 @@
+<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/drawer_layout"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:keepScreenOn="true"
+ tools:context="org.yuzu.yuzu_emu.fragments.EmulationFragment"
+ tools:openDrawer="start">
+
+ <androidx.coordinatorlayout.widget.CoordinatorLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <!-- This is what everything is rendered to during emulation -->
+ <org.yuzu.yuzu_emu.views.FixedRatioSurfaceView
+ android:id="@+id/surface_emulation"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="center"
+ android:focusable="false"
+ android:focusableInTouchMode="false" />
+
+ <FrameLayout
+ android:id="@+id/overlay_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_gravity="bottom">
+
+ <!-- This is the onscreen input overlay -->
+ <org.yuzu.yuzu_emu.overlay.InputOverlay
+ android:id="@+id/surface_input_overlay"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:focusable="true"
+ android:focusableInTouchMode="true" />
+
+ <TextView
+ android:id="@+id/show_fps_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="left"
+ android:clickable="false"
+ android:focusable="false"
+ android:shadowColor="@android:color/black"
+ android:textColor="@android:color/white"
+ android:textSize="12sp"
+ tools:ignore="RtlHardcoded" />
+
+ <Button
+ style="@style/Widget.Material3.Button.ElevatedButton"
+ android:id="@+id/done_control_config"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:text="@string/emulation_done"
+ android:visibility="gone" />
+ </FrameLayout>
+
+ </androidx.coordinatorlayout.widget.CoordinatorLayout>
+
+ <com.google.android.material.navigation.NavigationView
+ android:id="@+id/in_game_menu"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_gravity="start|bottom"
+ app:headerLayout="@layout/header_in_game"
+ app:menu="@menu/menu_in_game" />
+
+</androidx.drawerlayout.widget.DrawerLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_games.xml b/src/android/app/src/main/res/layout/fragment_games.xml
new file mode 100644
index 000000000..a0568668a
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_games.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/swipe_refresh"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface"
+ android:clipToPadding="false">
+
+ <RelativeLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/notice_text"
+ style="@style/TextAppearance.Material3.BodyLarge"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:gravity="center"
+ android:padding="@dimen/spacing_large"
+ android:text="@string/empty_gamelist"
+ android:visibility="gone" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/grid_games"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false"
+ tools:listitem="@layout/card_game" />
+
+ </RelativeLayout>
+
+</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_home_settings.xml b/src/android/app/src/main/res/layout/fragment_home_settings.xml
new file mode 100644
index 000000000..1cb421dcb
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_home_settings.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.core.widget.NestedScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/scroll_view_settings"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface"
+ android:scrollbars="vertical"
+ android:fadeScrollbars="false"
+ android:clipToPadding="false">
+
+ <androidx.appcompat.widget.LinearLayoutCompat
+ android:id="@+id/linear_layout_settings"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:background="?attr/colorSurface">
+
+ <ImageView
+ android:id="@+id/logo_image"
+ android:layout_width="128dp"
+ android:layout_height="128dp"
+ android:layout_margin="64dp"
+ android:layout_gravity="center_horizontal"
+ android:src="@drawable/ic_yuzu_full" />
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/home_settings_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent" />
+
+ </androidx.appcompat.widget.LinearLayoutCompat>
+
+</androidx.core.widget.NestedScrollView>
diff --git a/src/android/app/src/main/res/layout/fragment_licenses.xml b/src/android/app/src/main/res/layout/fragment_licenses.xml
new file mode 100644
index 000000000..6b31ff5b4
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_licenses.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/coordinator_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface">
+
+ <com.google.android.material.appbar.AppBarLayout
+ android:id="@+id/appbar_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fitsSystemWindows="true">
+
+ <com.google.android.material.appbar.MaterialToolbar
+ android:id="@+id/toolbar_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="?attr/actionBarSize"
+ app:title="@string/licenses"
+ app:navigationIcon="@drawable/ic_back" />
+
+ </com.google.android.material.appbar.AppBarLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_licenses"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_search.xml b/src/android/app/src/main/res/layout/fragment_search.xml
new file mode 100644
index 000000000..b8d54d947
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_search.xml
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/constraint_search"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface"
+ android:clipToPadding="false">
+
+ <RelativeLayout
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/divider">
+
+ <LinearLayout
+ android:id="@+id/no_results_view"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:gravity="center">
+
+ <ImageView
+ android:id="@+id/icon_no_results"
+ android:layout_width="match_parent"
+ android:layout_height="80dp"
+ android:src="@drawable/ic_search" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/notice_text"
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center"
+ android:paddingTop="8dp"
+ android:text="@string/search_and_filter_games"
+ tools:visibility="visible" />
+
+ </LinearLayout>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/grid_games_search"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:clipToPadding="false" />
+
+ </RelativeLayout>
+
+ <FrameLayout
+ android:id="@+id/frame_search"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:layout_marginHorizontal="20dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+
+ <com.google.android.material.card.MaterialCardView
+ android:id="@+id/search_background"
+ style="?attr/materialCardViewFilledStyle"
+ android:layout_width="match_parent"
+ android:layout_height="56dp"
+ app:cardCornerRadius="28dp">
+
+ <LinearLayout
+ android:id="@+id/search_container"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginStart="24dp"
+ android:layout_marginEnd="56dp"
+ android:orientation="horizontal">
+
+ <ImageView
+ android:layout_width="28dp"
+ android:layout_height="28dp"
+ android:layout_gravity="center_vertical"
+ android:layout_marginEnd="24dp"
+ android:src="@drawable/ic_search"
+ app:tint="?attr/colorOnSurfaceVariant" />
+
+ <EditText
+ android:id="@+id/search_text"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@android:color/transparent"
+ android:hint="@string/home_search_games"
+ android:inputType="text"
+ android:maxLines="1"
+ android:imeOptions="flagNoFullscreen" />
+
+ </LinearLayout>
+
+ <ImageView
+ android:id="@+id/clear_button"
+ android:layout_width="24dp"
+ android:layout_height="24dp"
+ android:layout_gravity="center_vertical|end"
+ android:layout_marginEnd="24dp"
+ android:background="?attr/selectableItemBackground"
+ android:src="@drawable/ic_clear"
+ android:visibility="invisible"
+ app:tint="?attr/colorOnSurfaceVariant"
+ tools:visibility="visible" />
+
+ </com.google.android.material.card.MaterialCardView>
+
+ </FrameLayout>
+
+ <HorizontalScrollView
+ android:id="@+id/horizontalScrollView"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fadingEdge="horizontal"
+ android:scrollbars="none"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/frame_search">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/chip_group"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:clipToPadding="false"
+ android:paddingVertical="4dp"
+ app:chipSpacingHorizontal="12dp"
+ app:singleLine="true"
+ app:singleSelection="true">
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_recently_played"
+ style="@style/Widget.Material3.Chip.Suggestion.Elevated"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="false"
+ android:text="@string/search_recently_played"
+ app:chipCornerRadius="28dp" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_recently_added"
+ style="@style/Widget.Material3.Chip.Suggestion.Elevated"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="false"
+ android:text="@string/search_recently_added"
+ app:chipCornerRadius="28dp" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_retail"
+ style="@style/Widget.Material3.Chip.Suggestion.Elevated"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="false"
+ android:text="@string/search_retail"
+ app:chipCornerRadius="28dp" />
+
+ <com.google.android.material.chip.Chip
+ android:id="@+id/chip_homebrew"
+ style="@style/Widget.Material3.Chip.Suggestion.Elevated"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:checked="false"
+ android:text="@string/search_homebrew"
+ app:chipCornerRadius="28dp" />
+
+ </com.google.android.material.chip.ChipGroup>
+
+ </HorizontalScrollView>
+
+ <com.google.android.material.divider.MaterialDivider
+ android:id="@+id/divider"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="20dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/horizontalScrollView" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_settings.xml b/src/android/app/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 000000000..167720347
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/list_settings"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="?attr/colorSurface"
+ android:clipToPadding="false" />
+
+</FrameLayout>
diff --git a/src/android/app/src/main/res/layout/fragment_setup.xml b/src/android/app/src/main/res/layout/fragment_setup.xml
new file mode 100644
index 000000000..d7bafaea2
--- /dev/null
+++ b/src/android/app/src/main/res/layout/fragment_setup.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/setup_root"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager2"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:clipToPadding="false"
+ android:layout_marginBottom="16dp"
+ app:layout_constraintBottom_toTopOf="@+id/button_next"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <com.google.android.material.button.MaterialButton
+ style="@style/Widget.Material3.Button.TextButton"
+ android:id="@+id/button_next"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="12dp"
+ android:text="@string/next"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+ <com.google.android.material.button.MaterialButton
+ style="@style/Widget.Material3.Button.TextButton"
+ android:id="@+id/button_back"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="12dp"
+ android:text="@string/back"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/layout/header_in_game.xml b/src/android/app/src/main/res/layout/header_in_game.xml
new file mode 100644
index 000000000..958cfb7e3
--- /dev/null
+++ b/src/android/app/src/main/res/layout/header_in_game.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.textview.MaterialTextView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:id="@+id/text_game_title"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:layout_marginStart="24dp"
+ android:layout_marginEnd="24dp"
+ android:textAppearance="?attr/textAppearanceHeadlineMedium"
+ android:textColor="?attr/colorOnSurface"
+ android:textAlignment="viewStart"
+ tools:text="Super Mario Odyssey" />
diff --git a/src/android/app/src/main/res/layout/list_item_setting.xml b/src/android/app/src/main/res/layout/list_item_setting.xml
new file mode 100644
index 000000000..ec896342b
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_setting.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:background="?android:attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:gravity="center_vertical"
+ android:minHeight="72dp"
+ android:padding="@dimen/spacing_large">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.HeadlineMedium"
+ android:id="@+id/text_setting_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentTop="true"
+ android:textSize="16sp"
+ android:textAlignment="viewStart"
+ app:lineHeight="28dp"
+ tools:text="Setting Name" />
+
+ <TextView
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:id="@+id/text_setting_description"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_alignParentStart="true"
+ android:layout_alignStart="@+id/text_setting_name"
+ android:layout_below="@+id/text_setting_name"
+ android:layout_marginTop="@dimen/spacing_small"
+ android:visibility="visible"
+ android:textAlignment="viewStart"
+ tools:text="@string/app_disclaimer" />
+
+</RelativeLayout>
diff --git a/src/android/app/src/main/res/layout/list_item_setting_switch.xml b/src/android/app/src/main/res/layout/list_item_setting_switch.xml
new file mode 100644
index 000000000..599d845ad
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_setting_switch.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:background="?android:attr/selectableItemBackground"
+ android:clickable="true"
+ android:focusable="true"
+ android:minHeight="72dp"
+ android:paddingStart="@dimen/spacing_large"
+ android:paddingEnd="24dp"
+ android:paddingVertical="@dimen/spacing_large">
+
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/switch_widget"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentEnd="true"
+ android:layout_centerVertical="true" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.BodySmall"
+ android:id="@+id/text_setting_description"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignStart="@+id/text_setting_name"
+ android:layout_below="@+id/text_setting_name"
+ android:layout_marginEnd="@dimen/spacing_large"
+ android:layout_marginTop="@dimen/spacing_small"
+ android:layout_toStartOf="@+id/switch_widget"
+ android:textAlignment="viewStart"
+ tools:text="@string/frame_limit_enable_description" />
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.HeadlineMedium"
+ android:id="@+id/text_setting_name"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_alignParentStart="true"
+ android:layout_alignParentTop="true"
+ android:layout_marginEnd="@dimen/spacing_large"
+ android:layout_toStartOf="@+id/switch_widget"
+ android:textSize="16sp"
+ android:textAlignment="viewStart"
+ app:lineHeight="28dp"
+ tools:text="@string/frame_limit_enable" />
+
+</RelativeLayout>
diff --git a/src/android/app/src/main/res/layout/list_item_settings_header.xml b/src/android/app/src/main/res/layout/list_item_settings_header.xml
new file mode 100644
index 000000000..abd24df6f
--- /dev/null
+++ b/src/android/app/src/main/res/layout/list_item_settings_header.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="48dp"
+ android:paddingVertical="4dp"
+ android:paddingHorizontal="@dimen/spacing_large">
+
+ <com.google.android.material.textview.MaterialTextView
+ style="@style/TextAppearance.Material3.TitleSmall"
+ android:id="@+id/text_header_name"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="start|center_vertical"
+ android:textColor="?attr/colorPrimary"
+ android:textAlignment="viewStart"
+ android:textStyle="bold"
+ tools:text="CPU Settings" />
+
+</FrameLayout>
diff --git a/src/android/app/src/main/res/layout/page_setup.xml b/src/android/app/src/main/res/layout/page_setup.xml
new file mode 100644
index 000000000..1436ef308
--- /dev/null
+++ b/src/android/app/src/main/res/layout/page_setup.xml
@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <ImageView
+ android:id="@+id/icon"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:layout_marginTop="64dp"
+ android:layout_marginBottom="32dp"
+ app:layout_constraintBottom_toTopOf="@+id/text_title"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHeight_max="220dp"
+ app:layout_constraintHeight_min="110dp"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ app:layout_constraintVertical_chainStyle="spread"
+ app:layout_constraintWidth_max="220dp"
+ app:layout_constraintWidth_min="110dp"
+ app:layout_constraintVertical_weight="3" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/text_title"
+ style="@style/TextAppearance.Material3.DisplayMedium"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:textAlignment="center"
+ android:textColor="?attr/colorOnSurface"
+ android:textStyle="bold"
+ app:layout_constraintBottom_toTopOf="@+id/text_description"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/icon"
+ app:layout_constraintVertical_weight="1.3"
+ tools:text="@string/welcome" />
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/text_description"
+ style="@style/TextAppearance.Material3.TitleLarge"
+ android:layout_width="0dp"
+ android:layout_height="0dp"
+ android:textAlignment="center"
+ android:textSize="26sp"
+ android:paddingHorizontal="16dp"
+ app:layout_constraintBottom_toTopOf="@+id/button_action"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/text_title"
+ app:layout_constraintVertical_weight="2"
+ app:lineHeight="40sp"
+ tools:text="@string/welcome_description" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/button_action"
+ android:layout_width="wrap_content"
+ android:layout_height="56dp"
+ android:textSize="20sp"
+ android:layout_marginTop="16dp"
+ android:layout_marginBottom="48dp"
+ app:iconGravity="end"
+ app:iconSize="24sp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/text_description"
+ tools:text="Get started" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml b/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml
new file mode 100644
index 000000000..dd7698e78
--- /dev/null
+++ b/src/android/app/src/main/res/menu-w600dp/menu_navigation.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <item
+ android:id="@+id/homeSettingsFragment"
+ android:icon="@drawable/selector_settings"
+ android:title="@string/home_settings" />
+
+ <item
+ android:id="@+id/searchFragment"
+ android:icon="@drawable/ic_search"
+ android:title="@string/home_search" />
+
+ <item
+ android:id="@+id/gamesFragment"
+ android:icon="@drawable/selector_cartridge"
+ android:title="@string/home_games" />
+
+</menu>
diff --git a/src/android/app/src/main/res/menu/menu_in_game.xml b/src/android/app/src/main/res/menu/menu_in_game.xml
new file mode 100644
index 000000000..f98f727b6
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_in_game.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <item
+ android:id="@+id/menu_pause_emulation"
+ android:icon="@drawable/ic_pause"
+ android:title="@string/emulation_pause" />
+
+ <item
+ android:id="@+id/menu_settings"
+ android:icon="@drawable/ic_settings"
+ android:title="@string/preferences_settings" />
+
+ <item
+ android:id="@+id/menu_overlay_controls"
+ android:icon="@drawable/ic_controller"
+ android:title="@string/emulation_input_overlay" />
+
+ <item
+ android:id="@+id/menu_exit"
+ android:icon="@drawable/ic_exit"
+ android:title="@string/emulation_exit" />
+
+</menu>
diff --git a/src/android/app/src/main/res/menu/menu_navigation.xml b/src/android/app/src/main/res/menu/menu_navigation.xml
new file mode 100644
index 000000000..da128c5a1
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_navigation.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <item
+ android:id="@+id/gamesFragment"
+ android:icon="@drawable/selector_cartridge"
+ android:title="@string/home_games" />
+
+ <item
+ android:id="@+id/searchFragment"
+ android:icon="@drawable/ic_search"
+ android:title="@string/home_search" />
+
+ <item
+ android:id="@+id/homeSettingsFragment"
+ android:icon="@drawable/selector_settings"
+ android:title="@string/home_settings" />
+
+</menu>
diff --git a/src/android/app/src/main/res/menu/menu_overlay_options.xml b/src/android/app/src/main/res/menu/menu_overlay_options.xml
new file mode 100644
index 000000000..4885b4f6f
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_overlay_options.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <item
+ android:id="@+id/menu_toggle_fps"
+ android:title="@string/emulation_fps_counter"
+ android:checkable="true" />
+
+ <item
+ android:id="@+id/menu_edit_overlay"
+ android:title="@string/emulation_touch_overlay_edit" />
+
+ <item
+ android:id="@+id/menu_adjust_overlay"
+ android:title="@string/emulation_control_adjust" />
+
+ <item
+ android:id="@+id/menu_toggle_controls"
+ android:title="@string/emulation_toggle_controls" />
+
+ <item
+ android:id="@+id/menu_show_overlay"
+ android:title="@string/emulation_show_overlay"
+ android:checkable="true" />
+
+ <item
+ android:id="@+id/menu_rel_stick_center"
+ android:title="@string/emulation_rel_stick_center"
+ android:checkable="true" />
+
+ <item
+ android:id="@+id/menu_dpad_slide"
+ android:title="@string/emulation_dpad_slide"
+ android:checkable="true" />
+
+ <item
+ android:id="@+id/menu_haptics"
+ android:title="@string/emulation_haptics"
+ android:checkable="true" />
+
+ <item
+ android:id="@+id/menu_reset_overlay"
+ android:title="@string/emulation_touch_overlay_reset" />
+
+</menu>
diff --git a/src/android/app/src/main/res/menu/menu_settings.xml b/src/android/app/src/main/res/menu/menu_settings.xml
new file mode 100644
index 000000000..1fe7aa6d4
--- /dev/null
+++ b/src/android/app/src/main/res/menu/menu_settings.xml
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu /> \ No newline at end of file
diff --git a/src/android/app/src/main/res/navigation/home_navigation.xml b/src/android/app/src/main/res/navigation/home_navigation.xml
new file mode 100644
index 000000000..48072683e
--- /dev/null
+++ b/src/android/app/src/main/res/navigation/home_navigation.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<navigation xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:id="@+id/home_navigation"
+ app:startDestination="@id/gamesFragment">
+
+ <fragment
+ android:id="@+id/gamesFragment"
+ android:name="org.yuzu.yuzu_emu.ui.GamesFragment"
+ android:label="PlatformGamesFragment" />
+
+ <fragment
+ android:id="@+id/homeSettingsFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.HomeSettingsFragment"
+ android:label="HomeSettingsFragment" >
+ <action
+ android:id="@+id/action_homeSettingsFragment_to_aboutFragment"
+ app:destination="@id/aboutFragment" />
+ <action
+ android:id="@+id/action_homeSettingsFragment_to_earlyAccessFragment"
+ app:destination="@id/earlyAccessFragment" />
+ </fragment>
+
+ <fragment
+ android:id="@+id/firstTimeSetupFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.SetupFragment"
+ android:label="FirstTimeSetupFragment" >
+ <action
+ android:id="@+id/action_firstTimeSetupFragment_to_gamesFragment"
+ app:destination="@id/gamesFragment"
+ app:popUpTo="@id/firstTimeSetupFragment"
+ app:popUpToInclusive="true" />
+ </fragment>
+
+ <fragment
+ android:id="@+id/searchFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.SearchFragment"
+ android:label="SearchFragment" />
+
+ <fragment
+ android:id="@+id/aboutFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.AboutFragment"
+ android:label="AboutFragment" >
+ <action
+ android:id="@+id/action_aboutFragment_to_licensesFragment"
+ app:destination="@id/licensesFragment" />
+ </fragment>
+
+ <fragment
+ android:id="@+id/earlyAccessFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.EarlyAccessFragment"
+ android:label="EarlyAccessFragment" />
+
+ <fragment
+ android:id="@+id/licensesFragment"
+ android:name="org.yuzu.yuzu_emu.fragments.LicensesFragment"
+ android:label="LicensesFragment" />
+
+</navigation>
diff --git a/src/android/app/src/main/res/values-night-v31/themes.xml b/src/android/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 000000000..631d7fd1b
--- /dev/null
+++ b/src/android/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="Theme.Yuzu.Main.MaterialYou" parent="Theme.Yuzu.Main">
+ <item name="colorPrimary">@color/m3_sys_color_dynamic_dark_primary</item>
+ <item name="colorOnPrimary">@color/m3_sys_color_dynamic_dark_on_primary</item>
+ <item name="colorPrimaryContainer">@color/m3_sys_color_dynamic_dark_primary_container</item>
+ <item name="colorOnPrimaryContainer">@color/m3_sys_color_dynamic_dark_on_primary_container</item>
+ <item name="colorSecondary">@color/m3_sys_color_dynamic_dark_secondary</item>
+ <item name="colorOnSecondary">@color/m3_sys_color_dynamic_dark_on_secondary</item>
+ <item name="colorSecondaryContainer">@color/m3_sys_color_dynamic_dark_secondary_container</item>
+ <item name="colorOnSecondaryContainer">@color/m3_sys_color_dynamic_dark_on_secondary_container</item>
+ <item name="colorTertiary">@color/m3_sys_color_dynamic_dark_tertiary</item>
+ <item name="colorOnTertiary">@color/m3_sys_color_dynamic_dark_on_tertiary</item>
+ <item name="colorTertiaryContainer">@color/m3_sys_color_dynamic_dark_tertiary_container</item>
+ <item name="colorOnTertiaryContainer">@color/m3_sys_color_dynamic_dark_on_tertiary_container</item>
+ <item name="android:colorBackground">@color/m3_sys_color_dynamic_dark_background</item>
+ <item name="colorOnBackground">@color/m3_sys_color_dynamic_dark_on_background</item>
+ <item name="colorSurface">@color/m3_sys_color_dynamic_dark_surface</item>
+ <item name="colorOnSurface">@color/m3_sys_color_dynamic_dark_on_surface</item>
+ <item name="colorSurfaceVariant">@color/m3_sys_color_dynamic_dark_surface_variant</item>
+ <item name="colorOnSurfaceVariant">@color/m3_sys_color_dynamic_dark_on_surface_variant</item>
+ <item name="colorOutline">@color/m3_sys_color_dynamic_dark_outline</item>
+ <item name="colorOnSurfaceInverse">@color/m3_sys_color_dynamic_dark_on_surface_variant</item>
+ <item name="colorSurfaceInverse">@color/m3_sys_color_dynamic_dark_surface_variant</item>
+ <item name="colorPrimaryInverse">@color/m3_sys_color_dynamic_dark_inverse_primary</item>
+
+ <item name="materialAlertDialogTheme">@style/ThemeOverlay.Material3.MaterialAlertDialog</item>
+ </style>
+
+</resources>
diff --git a/src/android/app/src/main/res/values-night/themes.xml b/src/android/app/src/main/res/values-night/themes.xml
new file mode 100644
index 000000000..d7d24c24d
--- /dev/null
+++ b/src/android/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="ThemeOverlay.Yuzu.Dark" parent="">
+ <item name="colorSurface">@android:color/black</item>
+ <item name="android:colorBackground">@android:color/black</item>
+ </style>
+
+</resources>
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
new file mode 100644
index 000000000..49d823324
--- /dev/null
+++ b/src/android/app/src/main/res/values-night/yuzu_colors.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <color name="yuzu_primary">#A7DDEC</color>
+ <color name="yuzu_onPrimary">#003399</color>
+ <color name="yuzu_primaryContainer">#31323F</color>
+ <color name="yuzu_onPrimaryContainer">#D1E4FF</color>
+ <color name="yuzu_secondary">#BAC8DB</color>
+ <color name="yuzu_onSecondary">#253140</color>
+ <color name="yuzu_secondaryContainer">#3B4858</color>
+ <color name="yuzu_onSecondaryContainer">#D6E4F7</color>
+ <color name="yuzu_tertiary">#D6BEE5</color>
+ <color name="yuzu_onTertiary">#3A2948</color>
+ <color name="yuzu_tertiaryContainer">#524060</color>
+ <color name="yuzu_onTertiaryContainer">#F2DAFF</color>
+ <color name="yuzu_error">#FFB4AB</color>
+ <color name="yuzu_errorContainer">#93000A</color>
+ <color name="yuzu_onError">#690005</color>
+ <color name="yuzu_onErrorContainer">#FFDAD6</color>
+ <color name="yuzu_background">#1A1C1E</color>
+ <color name="yuzu_onBackground">#E2E2E6</color>
+ <color name="yuzu_surface">#1B1B1D</color>
+ <color name="yuzu_onSurface">#E2E2E6</color>
+ <color name="yuzu_surfaceVariant">#26282C</color>
+ <color name="yuzu_onSurfaceVariant">#C3C7CF</color>
+ <color name="yuzu_outline">#8C9199</color>
+ <color name="yuzu_inverseOnSurface">#1A1C1E</color>
+ <color name="yuzu_inverseSurface">#E2E2E6</color>
+ <color name="yuzu_inversePrimary">#0062A2</color>
+ <color name="yuzu_shadow">#000000</color>
+ <color name="yuzu_surfaceTint">#9DCAFF</color>
+ <color name="yuzu_outlineVariant">#42474E</color>
+
+ <color name="yuzu_ea_background_start">#840099</color>
+ <color name="yuzu_ea_background_end">#005AE1</color>
+
+</resources>
diff --git a/src/android/app/src/main/res/values-v31/themes.xml b/src/android/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 000000000..5d3a86bf6
--- /dev/null
+++ b/src/android/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="Theme.Yuzu.Main.MaterialYou" parent="Theme.Yuzu.Main">
+ <item name="colorPrimary">@color/m3_sys_color_dynamic_light_primary</item>
+ <item name="colorOnPrimary">@color/m3_sys_color_dynamic_light_on_primary</item>
+ <item name="colorPrimaryContainer">@color/m3_sys_color_dynamic_light_primary_container</item>
+ <item name="colorOnPrimaryContainer">@color/m3_sys_color_dynamic_light_on_primary_container</item>
+ <item name="colorSecondary">@color/m3_sys_color_dynamic_light_secondary</item>
+ <item name="colorOnSecondary">@color/m3_sys_color_dynamic_light_on_secondary</item>
+ <item name="colorSecondaryContainer">@color/m3_sys_color_dynamic_light_secondary_container</item>
+ <item name="colorOnSecondaryContainer">@color/m3_sys_color_dynamic_light_on_secondary_container</item>
+ <item name="colorTertiary">@color/m3_sys_color_dynamic_light_tertiary</item>
+ <item name="colorOnTertiary">@color/m3_sys_color_dynamic_light_on_tertiary</item>
+ <item name="colorTertiaryContainer">@color/m3_sys_color_dynamic_light_tertiary_container</item>
+ <item name="colorOnTertiaryContainer">@color/m3_sys_color_dynamic_light_on_tertiary_container</item>
+ <item name="android:colorBackground">@color/m3_sys_color_dynamic_light_background</item>
+ <item name="colorOnBackground">@color/m3_sys_color_dynamic_light_on_background</item>
+ <item name="colorSurface">@color/m3_sys_color_dynamic_light_surface</item>
+ <item name="colorOnSurface">@color/m3_sys_color_dynamic_light_on_surface</item>
+ <item name="colorSurfaceVariant">@color/m3_sys_color_dynamic_light_surface_variant</item>
+ <item name="colorOnSurfaceVariant">@color/m3_sys_color_dynamic_light_on_surface_variant</item>
+ <item name="colorOutline">@color/m3_sys_color_dynamic_light_outline</item>
+ <item name="colorOnSurfaceInverse">@color/m3_sys_color_dynamic_light_on_surface_variant</item>
+ <item name="colorSurfaceInverse">@color/m3_sys_color_dynamic_light_surface_variant</item>
+ <item name="colorPrimaryInverse">@color/m3_sys_color_dynamic_light_inverse_primary</item>
+
+ <item name="materialAlertDialogTheme">@style/ThemeOverlay.Material3.MaterialAlertDialog</item>
+ </style>
+
+</resources>
diff --git a/src/android/app/src/main/res/values-w600dp/bools.xml b/src/android/app/src/main/res/values-w600dp/bools.xml
new file mode 100644
index 000000000..b6833a702
--- /dev/null
+++ b/src/android/app/src/main/res/values-w600dp/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <bool name="small_layout">false</bool>
+</resources>
diff --git a/src/android/app/src/main/res/values-w600dp/dimens.xml b/src/android/app/src/main/res/values-w600dp/dimens.xml
new file mode 100644
index 000000000..128319e27
--- /dev/null
+++ b/src/android/app/src/main/res/values-w600dp/dimens.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <dimen name="spacing_navigation">0dp</dimen>
+ <dimen name="spacing_navigation_rail">80dp</dimen>
+</resources>
diff --git a/src/android/app/src/main/res/values/arrays.xml b/src/android/app/src/main/res/values/arrays.xml
new file mode 100644
index 000000000..ea20cb17c
--- /dev/null
+++ b/src/android/app/src/main/res/values/arrays.xml
@@ -0,0 +1,227 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <string-array name="regionNames">
+ <item>@string/auto</item>
+ <item>@string/region_australia</item>
+ <item>@string/region_china</item>
+ <item>@string/region_europe</item>
+ <item>@string/region_japan</item>
+ <item>@string/region_korea</item>
+ <item>@string/region_taiwan</item>
+ <item>@string/region_usa</item>
+ </string-array>
+
+ <integer-array name="regionValues">
+ <item>-1</item>
+ <item>3</item>
+ <item>4</item>
+ <item>2</item>
+ <item>0</item>
+ <item>5</item>
+ <item>6</item>
+ <item>1</item>
+ </integer-array>
+
+ <string-array name="languageNames">
+ <item>@string/language_brazilian_portuguese</item>
+ <item>@string/language_british_english</item>
+ <item>@string/language_canadian_french</item>
+ <item>@string/language_chinese</item>
+ <item>@string/language_dutch</item>
+ <item>@string/language_english</item>
+ <item>@string/language_french</item>
+ <item>@string/langauge_german</item>
+ <item>@string/language_italian</item>
+ <item>@string/language_japanese</item>
+ <item>@string/language_korean</item>
+ <item>@string/language_latin_american_spanish</item>
+ <item>@string/language_portuguese</item>
+ <item>@string/language_russian</item>
+ <item>@string/language_simplified_chinese</item>
+ <item>@string/language_spanish</item>
+ <item>@string/language_taiwanese</item>
+ <item>@string/language_traditional_chinese</item>
+ </string-array>
+
+ <integer-array name="languageValues">
+ <item>17</item>
+ <item>12</item>
+ <item>13</item>
+ <item>6</item>
+ <item>8</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ <item>4</item>
+ <item>0</item>
+ <item>7</item>
+ <item>14</item>
+ <item>9</item>
+ <item>10</item>
+ <item>15</item>
+ <item>5</item>
+ <item>11</item>
+ <item>16</item>
+ </integer-array>
+
+ <string-array name="rendererApiNames">
+ <item>@string/renderer_vulkan</item>
+ <item>@string/renderer_none</item>
+ </string-array>
+
+ <integer-array name="rendererApiValues">
+ <item>1</item>
+ <item>2</item>
+ </integer-array>
+
+ <string-array name="rendererAccuracyNames">
+ <item>@string/renderer_accuracy_normal</item>
+ <item>@string/renderer_accuracy_high</item>
+ <item>@string/renderer_accuracy_extreme</item>
+ </string-array>
+
+ <integer-array name="rendererAccuracyValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ </integer-array>
+
+ <string-array name="rendererResolutionNames">
+ <item>@string/resolution_half</item>
+ <item>@string/resolution_three_quarter</item>
+ <item>@string/resolution_one</item>
+ <item>@string/resolution_two</item>
+ <item>@string/resolution_three</item>
+ <item>@string/resolution_four</item>
+ </string-array>
+
+ <string-array name="rendererVSyncNames">
+ <item>@string/renderer_vsync_immediate</item>
+ <item>@string/renderer_vsync_mailbox</item>
+ <item>@string/renderer_vsync_fifo</item>
+ <item>@string/renderer_vsync_fifo_relaxed</item>
+ </string-array>
+
+ <integer-array name="rendererResolutionValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ <item>4</item>
+ <item>5</item>
+ </integer-array>
+
+ <integer-array name="rendererVSyncValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ </integer-array>
+
+ <string-array name="rendererAspectRatioNames">
+ <item>@string/ratio_default</item>
+ <item>@string/ratio_force_four_three</item>
+ <item>@string/ratio_force_twenty_one_nine</item>
+ <item>@string/ratio_force_sixteen_ten</item>
+ <item>@string/ratio_stretch</item>
+ </string-array>
+
+ <integer-array name="rendererAspectRatioValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ <item>4</item>
+ </integer-array>
+
+ <string-array name="rendererScalingFilterNames">
+ <item>@string/scaling_filter_nearest_neighbor</item>
+ <item>@string/scaling_filter_bilinear</item>
+ <item>@string/scaling_filter_bicubic</item>
+ <item>@string/scaling_filter_gaussian</item>
+ <item>@string/scaling_filter_scale_force</item>
+ <item>@string/scaling_filter_fsr</item>
+ </string-array>
+
+ <integer-array name="rendererScalingFilterValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ <item>4</item>
+ <item>5</item>
+ </integer-array>
+
+ <string-array name="rendererAntiAliasingNames">
+ <item>@string/anti_aliasing_none</item>
+ <item>@string/anti_aliasing_fxaa</item>
+ <item>@string/anti_aliasing_smaa</item>
+ </string-array>
+
+ <integer-array name="rendererAntiAliasingValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ </integer-array>
+
+ <string-array name="cpuAccuracyNames">
+ <item>@string/auto</item>
+ <item>@string/cpu_accuracy_accurate</item>
+ <item>@string/cpu_accuracy_unsafe</item>
+ <item>@string/cpu_accuracy_paranoid</item>
+ </string-array>
+
+ <integer-array name="cpuAccuracyValues">
+ <item>0</item>
+ <item>1</item>
+ <item>2</item>
+ <item>3</item>
+ </integer-array>
+
+ <string-array name="gamepadButtons">
+ <item>A</item>
+ <item>B</item>
+ <item>X</item>
+ <item>Y</item>
+ <item>L</item>
+ <item>R</item>
+ <item>ZL</item>
+ <item>ZR</item>
+ <item>+</item>
+ <item>-</item>
+ <item>@string/gamepad_d_pad</item>
+ <item>@string/gamepad_left_stick</item>
+ <item>@string/gamepad_right_stick</item>
+ <item>@string/gamepad_home</item>
+ <item>@string/gamepad_screenshot</item>
+ </string-array>
+
+ <string-array name="themeEntries">
+ <item>@string/theme_default</item>
+ </string-array>
+ <integer-array name="themeValues">
+ <item>0</item>
+ </integer-array>
+
+ <string-array name="themeEntriesA12">
+ <item>@string/theme_default</item>
+ <item>@string/theme_material_you</item>
+ </string-array>
+ <integer-array name="themeValuesA12">
+ <item>0</item>
+ <item>1</item>
+ </integer-array>
+
+ <string-array name="themeModeEntries">
+ <item>@string/theme_mode_follow_system</item>
+ <item>@string/theme_mode_light</item>
+ <item>@string/theme_mode_dark</item>
+ </string-array>
+ <integer-array name="themeModeValues">
+ <item>-1</item>
+ <item>1</item>
+ <item>2</item>
+ </integer-array>
+
+</resources>
diff --git a/src/android/app/src/main/res/values/bools.xml b/src/android/app/src/main/res/values/bools.xml
new file mode 100644
index 000000000..e50f473fb
--- /dev/null
+++ b/src/android/app/src/main/res/values/bools.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <bool name="small_layout">true</bool>
+</resources>
diff --git a/src/android/app/src/main/res/values/dimens.xml b/src/android/app/src/main/res/values/dimens.xml
new file mode 100644
index 000000000..00757e5e8
--- /dev/null
+++ b/src/android/app/src/main/res/values/dimens.xml
@@ -0,0 +1,18 @@
+<resources>
+ <dimen name="spacing_small">4dp</dimen>
+ <dimen name="spacing_med">8dp</dimen>
+ <dimen name="spacing_medlarge">12dp</dimen>
+ <dimen name="spacing_large">16dp</dimen>
+ <dimen name="spacing_xtralarge">32dp</dimen>
+ <dimen name="spacing_list">64dp</dimen>
+ <dimen name="spacing_chip">20dp</dimen>
+ <dimen name="spacing_navigation">80dp</dimen>
+ <dimen name="spacing_navigation_rail">0dp</dimen>
+ <dimen name="spacing_search">128dp</dimen>
+ <dimen name="spacing_refresh_end">72dp</dimen>
+ <dimen name="menu_width">256dp</dimen>
+ <dimen name="card_width">165dp</dimen>
+
+ <dimen name="dialog_margin">20dp</dimen>
+ <dimen name="elevated_app_bar">3dp</dimen>
+</resources>
diff --git a/src/android/app/src/main/res/values/integers.xml b/src/android/app/src/main/res/values/integers.xml
new file mode 100644
index 000000000..bc614b81d
--- /dev/null
+++ b/src/android/app/src/main/res/values/integers.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <integer name="game_title_lines">2</integer>
+
+ <!-- Default SWITCH landscape layout -->
+ <integer name="SWITCH_BUTTON_A_X">760</integer>
+ <integer name="SWITCH_BUTTON_A_Y">790</integer>
+ <integer name="SWITCH_BUTTON_B_X">710</integer>
+ <integer name="SWITCH_BUTTON_B_Y">900</integer>
+ <integer name="SWITCH_BUTTON_X_X">710</integer>
+ <integer name="SWITCH_BUTTON_X_Y">680</integer>
+ <integer name="SWITCH_BUTTON_Y_X">660</integer>
+ <integer name="SWITCH_BUTTON_Y_Y">790</integer>
+ <integer name="SWITCH_STICK_L_X">100</integer>
+ <integer name="SWITCH_STICK_L_Y">670</integer>
+ <integer name="SWITCH_STICK_R_X">900</integer>
+ <integer name="SWITCH_STICK_R_Y">670</integer>
+ <integer name="SWITCH_TRIGGER_L_X">70</integer>
+ <integer name="SWITCH_TRIGGER_L_Y">220</integer>
+ <integer name="SWITCH_TRIGGER_R_X">930</integer>
+ <integer name="SWITCH_TRIGGER_R_Y">220</integer>
+ <integer name="SWITCH_TRIGGER_ZL_X">70</integer>
+ <integer name="SWITCH_TRIGGER_ZL_Y">90</integer>
+ <integer name="SWITCH_TRIGGER_ZR_X">930</integer>
+ <integer name="SWITCH_TRIGGER_ZR_Y">90</integer>
+ <integer name="SWITCH_BUTTON_MINUS_X">460</integer>
+ <integer name="SWITCH_BUTTON_MINUS_Y">950</integer>
+ <integer name="SWITCH_BUTTON_PLUS_X">540</integer>
+ <integer name="SWITCH_BUTTON_PLUS_Y">950</integer>
+ <integer name="SWITCH_BUTTON_HOME_X">600</integer>
+ <integer name="SWITCH_BUTTON_HOME_Y">950</integer>
+ <integer name="SWITCH_BUTTON_CAPTURE_X">400</integer>
+ <integer name="SWITCH_BUTTON_CAPTURE_Y">950</integer>
+ <integer name="SWITCH_BUTTON_DPAD_X">260</integer>
+ <integer name="SWITCH_BUTTON_DPAD_Y">790</integer>
+
+</resources>
diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..0ae69afb4
--- /dev/null
+++ b/src/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,866 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <!-- General application strings -->
+ <string name="app_name" translatable="false">yuzu</string>
+ <string name="app_disclaimer">This software will run games for the Nintendo Switch game console. No game titles or keys are included.&lt;br /&gt;&lt;br /&gt;Before you begin, please locate your <![CDATA[<b> prod.keys </b>]]> file on your device storage.&lt;br /&gt;&lt;br /&gt;<![CDATA[<a href="https://yuzu-emu.org/help/quickstart">Learn more</a>]]></string>
+ <string name="emulation_notification_channel_name">Emulation is Active</string>
+ <string name="emulation_notification_channel_id" translatable="false">emulationIsActive</string>
+ <string name="emulation_notification_channel_description">Shows a persistent notification when emulation is running.</string>
+ <string name="emulation_notification_running">yuzu is running</string>
+ <string name="notice_notification_channel_name">Notices and errors</string>
+ <string name="notice_notification_channel_id" translatable="false">noticesAndErrors</string>
+ <string name="notice_notification_channel_description">Shows notifications when something goes wrong.</string>
+ <string name="notification_permission_not_granted">Notification permission not granted!</string>
+
+ <!-- Setup strings -->
+ <string name="welcome">Welcome!</string>
+ <string name="welcome_description">Learn how to setup &lt;b>yuzu&lt;/b> and jump into emulation.</string>
+ <string name="get_started">Get started</string>
+ <string name="keys">Keys</string>
+ <string name="keys_description">Select your &lt;b>prod.keys&lt;/b> file with the button below.</string>
+ <string name="select_keys">Select Keys</string>
+ <string name="games">Games</string>
+ <string name="games_description">Select your &lt;b>Games&lt;/b> folder with the button below.</string>
+ <string name="done">Done</string>
+ <string name="done_description">You\'re all set.\nEnjoy your games!</string>
+ <string name="text_continue">Continue</string>
+ <string name="next">Next</string>
+ <string name="back">Back</string>
+ <string name="add_games">Add Games</string>
+ <string name="add_games_description">Select your games folder</string>
+
+ <!-- Home strings -->
+ <string name="home_games">Games</string>
+ <string name="home_search">Search</string>
+ <string name="home_settings">Settings</string>
+ <string name="empty_gamelist">No files were found or no game directory has been selected yet.</string>
+ <string name="search_and_filter_games">Search and filter games</string>
+ <string name="select_games_folder">Select games folder</string>
+ <string name="select_games_folder_description">Allows yuzu to populate the games list</string>
+ <string name="add_games_warning">Skip selecting games folder?</string>
+ <string name="add_games_warning_description">Games won\'t be displayed in the Games list if a folder isn\'t selected.</string>
+ <string name="add_games_warning_help">https://yuzu-emu.org/help/quickstart/#dumping-games</string>
+ <string name="home_search_games">Search games</string>
+ <string name="games_dir_selected">Games directory selected</string>
+ <string name="install_prod_keys">Install prod.keys</string>
+ <string name="install_prod_keys_description">Required to decrypt retail games</string>
+ <string name="install_prod_keys_warning">Skip adding keys?</string>
+ <string name="install_prod_keys_warning_description">Valid keys are required to emulate retail games. Only homebrew apps will function if you continue.</string>
+ <string name="install_prod_keys_warning_help">https://yuzu-emu.org/help/quickstart/#guide-introduction</string>
+ <string name="notifications">Notifications</string>
+ <string name="notifications_description">Grant the notification permission with the button below.</string>
+ <string name="give_permission">Grant permission</string>
+ <string name="notification_warning">Skip granting the notification permission?</string>
+ <string name="notification_warning_description">yuzu won\'t be able to notify you of important information.</string>
+ <string name="permission_denied">Permission denied</string>
+ <string name="permission_denied_description">You denied this permission too many times and now you have to manually grant it in system settings.</string>
+ <string name="about">About</string>
+ <string name="about_description">Build version, credits, and more</string>
+ <string name="warning_help">Help</string>
+ <string name="warning_skip">Skip</string>
+ <string name="warning_cancel">Cancel</string>
+ <string name="install_amiibo_keys">Install Amiibo keys</string>
+ <string name="install_amiibo_keys_description">Required to use Amiibo in game</string>
+ <string name="invalid_keys_file">Invalid keys file selected</string>
+ <string name="install_keys_success">Keys successfully installed</string>
+ <string name="reading_keys_failure">Error reading encryption keys</string>
+ <string name="install_keys_failure_extension_description">
+ 1. Verify your keys have the .keys extension.\n\n
+ 2. Keys must not be stored in the Downloads folder.\n\n
+ Resolve the issue(s) and try again.
+ </string>
+ <string name="invalid_keys_error">Invalid encryption keys</string>
+ <string name="dumping_keys_quickstart_link">https://yuzu-emu.org/help/quickstart/#dumping-decryption-keys</string>
+ <string name="install_keys_failure_description">The selected file is incorrect or corrupt. Please redump your keys.</string>
+ <string name="install_gpu_driver">Install GPU driver</string>
+ <string name="install_gpu_driver_description">Install alternative drivers for potentially better performance or accuracy</string>
+ <string name="advanced_settings">Advanced settings</string>
+ <string name="settings_description">Configure emulator settings</string>
+ <string name="search_recently_played">Recently played</string>
+ <string name="search_recently_added">Recently added</string>
+ <string name="search_retail">Retail</string>
+ <string name="search_homebrew">Homebrew</string>
+ <string name="open_user_folder">Open yuzu folder</string>
+ <string name="open_user_folder_description">Manage yuzu\'s internal files</string>
+ <string name="theme_and_color_description">Modify the look of the app</string>
+ <string name="no_file_manager">No file manager found</string>
+ <string name="notification_no_directory_link">Could not open yuzu directory</string>
+ <string name="notification_no_directory_link_description">Please locate the user folder with the file manager\'s side panel manually.</string>
+ <string name="manage_save_data">Manage save data</string>
+ <string name="manage_save_data_description">Save data found. Please select an option below.</string>
+ <string name="import_export_saves_description">Import or export save files</string>
+ <string name="import_export_saves_no_profile">No save data found. Please launch a game and retry.</string>
+ <string name="save_file_imported_success">Imported successfully</string>
+ <string name="save_file_invalid_zip_structure">Invalid save directory structure</string>
+ <string name="save_file_invalid_zip_structure_description">The first subfolder name must be the title ID of the game.</string>
+ <string name="import_saves">Import</string>
+ <string name="export_saves">Export</string>
+ <string name="install_firmware">Install firmware</string>
+ <string name="install_firmware_description">Firmware must be in a ZIP archive and is needed to boot some games</string>
+ <string name="firmware_installing">Installing firmware</string>
+ <string name="firmware_installed_success">Firmware installed successfully</string>
+ <string name="firmware_installed_failure">Firmware installation failed</string>
+ <string name="firmware_installed_failure_description">Verify that the ZIP contains valid firmware and try again.</string>
+ <string name="share_log">Share debug logs</string>
+ <string name="share_log_description">Share yuzu\'s log file to debug issues</string>
+ <string name="share_log_missing">No log file found</string>
+
+ <!-- About screen strings -->
+ <string name="gaia_is_not_real">Gaia isn\'t real</string>
+ <string name="copied_to_clipboard">Copied to clipboard</string>
+ <string name="about_app_description">An open-source Switch emulator</string>
+ <string name="contributors">Contributors</string>
+ <string name="contributors_description">Made with \u2764 from the yuzu team</string>
+ <string name="contributors_link">https://github.com/yuzu-emu/yuzu/graphs/contributors</string>
+ <string name="licenses_description">Projects that make yuzu for Android possible</string>
+ <string name="build">Build</string>
+ <string name="support_link">https://discord.gg/u77vRWY</string>
+ <string name="website_link">https://yuzu-emu.org/</string>
+ <string name="github_link">https://github.com/yuzu-emu</string>
+
+ <!-- Early access upgrade strings -->
+ <string name="early_access">Early Access</string>
+ <string name="get_early_access">Get Early Access</string>
+ <string name="play_store_link">https://play.google.com/store/apps/details?id=org.yuzu.yuzu_emu.ea</string>
+ <string name="get_early_access_description">Cutting-edge features, early access to updates, and more</string>
+ <string name="early_access_benefits">Early Access Benefits</string>
+ <string name="cutting_edge_features">Cutting-edge features</string>
+ <string name="early_access_updates">Early access to updates</string>
+ <string name="no_manual_installation">No manual installation</string>
+ <string name="prioritized_support">Prioritized support</string>
+ <string name="helping_game_preservation">Helping game preservation</string>
+ <string name="our_eternal_gratitude">Our eternal gratitude</string>
+ <string name="are_you_interested">Are you interested?</string>
+
+ <!-- General settings strings -->
+ <string name="frame_limit_enable">Limit speed</string>
+ <string name="frame_limit_enable_description">Limits emulation speed to a specified percentage of normal speed.</string>
+ <string name="frame_limit_slider">Limit speed percent</string>
+ <string name="frame_limit_slider_description">Specifies the percentage to limit emulation speed. 100% is the normal speed. Values higher or lower will increase or decrease the speed limit.</string>
+ <string name="cpu_accuracy">CPU accuracy</string>
+
+ <!-- System settings strings -->
+ <string name="use_docked_mode">Docked Mode</string>
+ <string name="use_docked_mode_description">Increases resolution, decreasing performance. Handheld Mode is used when disabled, lowering resolution and increasing performance.</string>
+ <string name="emulated_region">Emulated region</string>
+ <string name="emulated_language">Emulated language</string>
+ <string name="select_rtc_date">Select RTC date</string>
+ <string name="select_rtc_time">Select RTC time</string>
+ <string name="use_custom_rtc">Custom RTC</string>
+ <string name="use_custom_rtc_description">Allows you to set a custom real-time clock separate from your current system time.</string>
+ <string name="set_custom_rtc">Set custom RTC</string>
+
+ <!-- Graphics settings strings -->
+ <string name="renderer_api">API</string>
+ <string name="renderer_accuracy">Accuracy level</string>
+ <string name="renderer_resolution">Resolution (Handheld/Docked)</string>
+ <string name="renderer_vsync">VSync mode</string>
+ <string name="renderer_aspect_ratio">Aspect ratio</string>
+ <string name="renderer_scaling_filter">Window adapting filter</string>
+ <string name="renderer_anti_aliasing">Anti-aliasing method</string>
+ <string name="renderer_force_max_clock">Force maximum clocks (Adreno only)</string>
+ <string name="renderer_force_max_clock_description">Forces the GPU to run at the maximum possible clocks (thermal constraints will still be applied).</string>
+ <string name="renderer_asynchronous_shaders">Use asynchronous shaders</string>
+ <string name="renderer_asynchronous_shaders_description">Compiles shaders asynchronously, reducing stutter but may introduce glitches.</string>
+ <string name="renderer_debug">Graphics debugging</string>
+ <string name="renderer_debug_description">Sets the graphics API to a slow debugging mode.</string>
+ <string name="use_disk_shader_cache">Disk shader cache</string>
+ <string name="use_disk_shader_cache_description">Reduces stuttering by locally storing and loading generated shaders.</string>
+
+ <!-- Audio settings strings -->
+ <string name="audio_volume">Volume</string>
+ <string name="audio_volume_description">Specifies the volume of audio output.</string>
+
+ <!-- Miscellaneous -->
+ <string name="slider_default">Default</string>
+ <string name="ini_saved">Saved settings</string>
+ <string name="gameid_saved">Saved settings for %1$s</string>
+ <string name="error_saving">Error saving %1$s.ini: %2$s</string>
+ <string name="loading">Loading…</string>
+ <string name="reset_setting_confirmation">Do you want to reset this setting back to its default value?</string>
+ <string name="reset_to_default">Reset to default</string>
+ <string name="reset_all_settings">Reset all settings?</string>
+ <string name="reset_all_settings_description">All advanced settings will be reset to their default configuration. This can not be undone.</string>
+ <string name="settings_reset">Settings reset</string>
+ <string name="close">Close</string>
+ <string name="learn_more">Learn more</string>
+ <string name="auto">Auto</string>
+ <string name="submit">Submit</string>
+
+ <!-- GPU driver installation -->
+ <string name="select_gpu_driver">Select GPU driver</string>
+ <string name="select_gpu_driver_title">Would you like to replace your current GPU driver?</string>
+ <string name="select_gpu_driver_install">Install</string>
+ <string name="select_gpu_driver_default">Default</string>
+ <string name="select_gpu_driver_install_success">Installed %s</string>
+ <string name="select_gpu_driver_use_default">Using default GPU driver</string>
+ <string name="select_gpu_driver_error">Invalid driver selected, using system default!</string>
+ <string name="system_gpu_driver">System GPU driver</string>
+ <string name="installing_driver">Installing driver…</string>
+
+ <!-- Preferences Screen -->
+ <string name="preferences_settings">Settings</string>
+ <string name="preferences_general">General</string>
+ <string name="preferences_system">System</string>
+ <string name="preferences_graphics">Graphics</string>
+ <string name="preferences_audio">Audio</string>
+ <string name="preferences_theme">Theme and color</string>
+ <string name="preferences_debug">Debug</string>
+
+ <!-- ROM loading errors -->
+ <string name="loader_error_encrypted">Your ROM is encrypted</string>
+ <string name="loader_error_encrypted_roms_description"><![CDATA[Please follow the guides to redump your <a href="https://yuzu-emu.org/help/quickstart/#dumping-cartridge-games">game cartidges</a> or <a href="https://yuzu-emu.org/help/quickstart/#dumping-installed-titles-eshop">installed titles</a>.]]></string>
+ <string name="loader_error_encrypted_keys_description"><![CDATA[Please ensure your <a href="https://yuzu-emu.org/help/quickstart/#dumping-prodkeys-and-titlekeys">prod.keys</a> file is installed so that games can be decrypted.]]></string>
+ <string name="loader_error_video_core">An error occurred initializing the video core</string>
+ <string name="loader_error_video_core_description">This is usually caused by an incompatible GPU driver. Installing a custom GPU driver may resolve this problem.</string>
+ <string name="loader_error_invalid_format">Unable to load ROM</string>
+ <string name="loader_error_file_not_found">ROM file does not exist</string>
+
+ <!-- Emulation Menu -->
+ <string name="emulation_exit">Exit emulation</string>
+ <string name="emulation_done">Done</string>
+ <string name="emulation_fps_counter">FPS counter</string>
+ <string name="emulation_toggle_controls">Toggle controls</string>
+ <string name="emulation_rel_stick_center">Relative stick center</string>
+ <string name="emulation_dpad_slide">D-pad slide</string>
+ <string name="emulation_haptics">Touch haptics</string>
+ <string name="emulation_show_overlay">Show overlay</string>
+ <string name="emulation_toggle_all">Toggle all</string>
+ <string name="emulation_control_adjust">Adjust overlay</string>
+ <string name="emulation_control_scale">Scale</string>
+ <string name="emulation_control_opacity">Opacity</string>
+ <string name="emulation_touch_overlay_reset">Reset overlay</string>
+ <string name="emulation_touch_overlay_edit">Edit overlay</string>
+ <string name="emulation_pause">Pause emulation</string>
+ <string name="emulation_unpause">Unpause emulation</string>
+ <string name="emulation_input_overlay">Overlay options</string>
+ <string name="emulation_game_loading">Game loading…</string>
+
+ <string name="load_settings">Loading settings…</string>
+
+ <!-- Software keyboard -->
+ <string name="software_keyboard">Software keyboard</string>
+
+ <!-- Errors and warnings -->
+ <string name="abort_button">Abort</string>
+ <string name="continue_button">Continue</string>
+ <string name="system_archive_not_found">System Archive Not Found</string>
+ <string name="system_archive_not_found_message">%s is missing. Please dump your system archives.\nContinuing emulation may result in crashes and bugs.</string>
+ <string name="system_archive_general">A system archive</string>
+ <string name="save_load_error">Save/Load Error</string>
+ <string name="fatal_error">Fatal Error</string>
+ <string name="fatal_error_message">A fatal error occurred. Check the log for details.\nContinuing emulation may result in crashes and bugs.</string>
+ <string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string>
+
+ <!-- Region Names -->
+ <string name="region_japan">Japan</string>
+ <string name="region_usa">USA</string>
+ <string name="region_europe">Europe</string>
+ <string name="region_australia">Australia</string>
+ <string name="region_china">China</string>
+ <string name="region_korea">Korea</string>
+ <string name="region_taiwan">Taiwan</string>
+
+ <!-- Language Names -->
+ <string name="language_japanese">Japanese (日本語)</string>
+ <string name="language_english">English</string>
+ <string name="language_french">French (Français)</string>
+ <string name="langauge_german">German (Deutsch)</string>
+ <string name="language_italian">Italian (Italiano)</string>
+ <string name="language_spanish">Spanish (Español)</string>
+ <string name="language_chinese">Chinese (简体中文)</string>
+ <string name="language_korean">Korean (한국어)</string>
+ <string name="language_dutch">Dutch (Nederlands)</string>
+ <string name="language_portuguese">Portuguese (Português)</string>
+ <string name="language_russian">Russian (Русский)</string>
+ <string name="language_taiwanese">Taiwanese (台湾)</string>
+ <string name="language_british_english">British English</string>
+ <string name="language_canadian_french">Canadian French (Français canadien)</string>
+ <string name="language_latin_american_spanish">Latin American Spanish (Español latinoamericano)</string>
+ <string name="language_simplified_chinese">Simplified Chinese (简体中文)</string>
+ <string name="language_traditional_chinese">Traditional Chinese (正體中文)</string>
+ <string name="language_brazilian_portuguese">Brazilian Portuguese (Português do Brasil)</string>
+
+ <!-- Renderer APIs -->
+ <string name="renderer_vulkan">Vulkan</string>
+ <string name="renderer_none">None</string>
+
+ <!-- Renderer Accuracy -->
+ <string name="renderer_accuracy_normal">Normal</string>
+ <string name="renderer_accuracy_high">High</string>
+ <string name="renderer_accuracy_extreme">Extreme (Slow)</string>
+
+ <!-- Resolutions -->
+ <string name="resolution_half">0.5X (360p/540p)</string>
+ <string name="resolution_three_quarter">0.75X (540p/810p)</string>
+ <string name="resolution_one">1X (720p/1080p)</string>
+ <string name="resolution_two">2X (1440p/2160p) (Slow)</string>
+ <string name="resolution_three">3X (2160p/3240p) (Slow)</string>
+ <string name="resolution_four">4X (2880p/4320p) (Slow)</string>
+
+ <!-- Renderer VSync -->
+ <string name="renderer_vsync_immediate">Immediate (Off)</string>
+ <string name="renderer_vsync_mailbox">Mailbox</string>
+ <string name="renderer_vsync_fifo">FIFO (On)</string>
+ <string name="renderer_vsync_fifo_relaxed">FIFO Relaxed</string>
+
+ <!-- Scaling Filters -->
+ <string name="scaling_filter_nearest_neighbor">Nearest Neighbor</string>
+ <string name="scaling_filter_bilinear">Bilinear</string>
+ <string name="scaling_filter_bicubic">Bicubic</string>
+ <string name="scaling_filter_gaussian">Gaussian</string>
+ <string name="scaling_filter_scale_force">ScaleForce</string>
+ <string name="scaling_filter_fsr">AMD FidelityFX™ Super Resolution</string>
+
+ <!-- Anti-Aliasing -->
+ <string name="anti_aliasing_none">None</string>
+ <string name="anti_aliasing_fxaa">FXAA</string>
+ <string name="anti_aliasing_smaa">SMAA</string>
+
+ <!-- Aspect Ratios -->
+ <string name="ratio_default">Default (16:9)</string>
+ <string name="ratio_force_four_three">Force 4:3</string>
+ <string name="ratio_force_twenty_one_nine">Force 21:9</string>
+ <string name="ratio_force_sixteen_ten">Force 16:10</string>
+ <string name="ratio_stretch">Stretch to window</string>
+
+ <!-- CPU Accuracy -->
+ <string name="cpu_accuracy_accurate">Accurate</string>
+ <string name="cpu_accuracy_unsafe">Unsafe</string>
+ <string name="cpu_accuracy_paranoid">Paranoid (Slow)</string>
+
+ <!-- Gamepad Buttons -->
+ <string name="gamepad_d_pad">D-pad</string>
+ <string name="gamepad_left_stick">Left stick</string>
+ <string name="gamepad_right_stick">Right stick</string>
+ <string name="gamepad_home">Home</string>
+ <string name="gamepad_screenshot">Screenshot</string>
+
+ <!-- Disk shader cache -->
+ <string name="preparing_shaders">Preparing shaders</string>
+ <string name="building_shaders">Building shaders</string>
+
+ <!-- Theme options -->
+ <string name="change_app_theme">Change app theme</string>
+ <string name="theme_default">Default</string>
+ <string name="theme_material_you">Material You</string>
+
+ <!-- Theme Modes -->
+ <string name="change_theme_mode">Change theme mode</string>
+ <string name="theme_mode_follow_system">Follow System</string>
+ <string name="theme_mode_light">Light</string>
+ <string name="theme_mode_dark">Dark</string>
+
+ <!-- Black backgrounds theme -->
+ <string name="use_black_backgrounds">Black backgrounds</string>
+ <string name="use_black_backgrounds_description">When using the dark theme, apply black backgrounds.</string>
+
+ <!-- Licenses screen strings -->
+ <string name="licenses">Licenses</string>
+ <string name="license_fidelityfx_fsr" translatable="false">FidelityFX-FSR</string>
+ <string name="license_fidelityfx_fsr_description">High-quality upscaling from AMD</string>
+ <string name="license_fidelityfx_fsr_link" translatable="false">https://github.com/GPUOpen-Effects/FidelityFX-FSR</string>
+ <string name="license_fidelityfx_fsr_copyright" translatable="false">Copyright © 2021 Advanced Micro Devices, Inc.</string>
+ <string name="license_fidelityfx_fsr_text" translatable="false">
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the \"Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:\n\n
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.\n\n
+
+THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+ </string>
+ <string name="license_cubeb" translatable="false">cubeb</string>
+ <string name="license_cubeb_description" translatable="false">Cross platform audio library</string>
+ <string name="license_cubeb_link" translatable="false">https://github.com/mozilla/cubeb</string>
+ <string name="license_cubeb_copyright" translatable="false">Copyright © 2011 Mozilla Foundation</string>
+ <string name="license_cubeb_text" translatable="false">
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.\n\n
+
+THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ </string>
+ <string name="license_dynarmic" translatable="false">Dynarmic</string>
+ <string name="license_dynarmic_description" translatable="false">An ARM dynamic recompiler</string>
+ <string name="license_dynarmic_link" translatable="false">https://github.com/merryhime/dynarmic</string>
+ <string name="license_dynarmic_copyright" translatable="false">Copyright © 2017 merryhime</string>
+ <string name="license_dynarmic_text" translatable="false">
+Permission to use, copy, modify, and/or distribute this software for
+any purpose with or without fee is hereby granted.\n\n
+
+THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ </string>
+ <string name="license_ffmpeg" translatable="false">FFmpeg</string>
+ <string name="license_ffmpeg_description" translatable="false">FFmpeg is a collection of libraries and tools to process multimedia content such as audio, video, subtitles and related metadata.</string>
+ <string name="license_ffmpeg_link" translatable="false">https://github.com/FFmpeg/FFmpeg</string>
+ <string name="license_ffmpeg_copyright" translatable="false">Copyright © 1991, 1999 Free Software Foundation, Inc.</string>
+ <string name="license_ffmpeg_text" translatable="false">
+GNU LESSER GENERAL PUBLIC LICENSE\n
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n
+
+ 0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called \"this License\").
+Each licensee is addressed as \"you\".\n\n
+
+ A \"library\" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.\n\n
+
+ The \"Library\", below, refers to any such software library or work
+which has been distributed under these terms. A \"work based on the
+Library\" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language. (Hereinafter, translation is
+included without limitation in the term \"modification\".)\n\n
+
+ \"Source code\" for a work means the preferred form of the work for
+making modifications to it. For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.\n\n
+
+ Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it). Whether that is true depends on what the Library does
+and what the program that uses the Library does.\n\n
+
+ 1. You may copy and distribute verbatim copies of the Library\'s
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.\n\n
+
+ You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.\n\n
+
+ 2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:\n\n
+
+a) The modified work must itself be a software library.\n\n
+
+b) You must cause the files modified to carry prominent notices
+stating that you changed the files and the date of any change.\n\n
+
+c) You must cause the whole of the work to be licensed at no
+charge to all third parties under the terms of this License.\n\n
+
+d) If a facility in the modified Library refers to a function or a
+table of data to be supplied by an application program that uses
+the facility, other than as an argument passed when the facility
+is invoked, then you must make a good faith effort to ensure that,
+in the event an application does not supply such function or
+table, the facility still operates, and performs whatever part of
+its purpose remains meaningful.\n\n
+
+(For example, a function in a library to compute square roots has
+a purpose that is entirely well-defined independent of the
+application. Therefore, Subsection 2d requires that any
+application-supplied function or table used by this function must
+be optional: if the application does not supply it, the square
+root function must still compute square roots.)\n\n
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.\n\n
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.\n\n
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.\n\n
+
+ 3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library. To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License. (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.) Do not make any other change in
+these notices.\n\n
+
+ Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.\n\n
+
+ This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.\n\n
+
+ 4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.\n\n
+
+ If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.\n\n
+
+ 5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a \"work that uses the Library\". Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.\n\n
+
+ However, linking a \"work that uses the Library\" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a \"work that uses the
+library\". The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.\n\n
+
+ When a \"work that uses the Library\" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library. The
+threshold for this to be true is not precisely defined by law.\n\n
+
+ If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work. (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)\n\n
+
+ Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.\n\n
+
+ 6. As an exception to the Sections above, you may also combine or
+link a \"work that uses the Library\" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer\'s own use and reverse
+engineering for debugging such modifications.\n\n
+
+ You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License. You must supply a copy of this License. If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License. Also, you must do one
+of these things:\n\n
+
+a) Accompany the work with the complete corresponding
+machine-readable source code for the Library including whatever
+changes were used in the work (which must be distributed under
+Sections 1 and 2 above); and, if the work is an executable linked
+with the Library, with the complete machine-readable \"work that
+uses the Library\", as object code and/or source code, so that the
+user can modify the Library and then relink to produce a modified
+executable containing the modified Library. (It is understood
+that the user who changes the contents of definitions files in the
+Library will not necessarily be able to recompile the application
+to use the modified definitions.)\n\n
+
+b) Use a suitable shared library mechanism for linking with the
+Library. A suitable mechanism is one that (1) uses at run time a
+copy of the library already present on the user\'s computer system,
+rather than copying library functions into the executable, and (2)
+will operate properly with a modified version of the library, if
+the user installs one, as long as the modified version is
+interface-compatible with the version that the work was made with.\n\n
+
+c) Accompany the work with a written offer, valid for at
+least three years, to give the same user the materials
+specified in Subsection 6a, above, for a charge no more
+than the cost of performing this distribution.\n\n
+
+d) If distribution of the work is made by offering access to copy
+from a designated place, offer equivalent access to copy the above
+specified materials from the same place.\n\n
+
+e) Verify that the user has already received a copy of these
+materials or that you have already sent this user a copy.\n\n
+
+ For an executable, the required form of the \"work that uses the
+Library\" must include any data and utility programs needed for
+reproducing the executable from it. However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.\n\n
+
+ It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system. Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.\n\n
+
+ 7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:\n\n
+
+a) Accompany the combined library with a copy of the same work
+based on the Library, uncombined with any other library
+facilities. This must be distributed under the terms of the
+Sections above.\n\n
+
+b) Give prominent notice with the combined library of the fact
+that part of it is a work based on the Library, and explaining
+where to find the accompanying uncombined form of the same work.\n\n
+
+ 8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License. Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.\n\n
+
+ 9. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Library or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.\n\n
+
+ 10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions. You may not impose any further
+restrictions on the recipients\' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.\n\n
+
+ 11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all. For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.\n\n
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.\n\n
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.\n\n
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.\n\n
+
+ 12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded. In such case, this License incorporates the limitation as if
+written in the body of this License.\n\n
+
+ 13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.\n\n
+
+Each version is given a distinguishing version number. If the Library
+specifies a version number of this License which applies to it and
+"any later version\", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation. If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.\n\n
+
+ 14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission. For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this. Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.\n\n
+
+NO WARRANTY\n\n
+
+ 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY \"AS IS\" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n
+
+ 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+ </string>
+ <string name="license_opus" translatable="false">Opus</string>
+ <string name="license_opus_description" translatable="false">Modern audio compression for the internet</string>
+ <string name="license_opus_link" translatable="false">https://github.com/xiph/opus</string>
+ <string name="license_opus_copyright" translatable="false">Copyright 2001–2011 Xiph.Org, Skype Limited, Octasic, Jean-Marc Valin, Timothy B. Terriberry, CSIRO, Gregory Maxwell, Mark Borgerding, Erik de Castro Lopo</string>
+ <string name="license_opus_text" translatable="false">
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:\n\n
+
+- Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.\n\n
+
+- Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.\n\n
+
+- Neither the name of Internet Society, IETF or IETF Trust, nor the
+names of specific contributors, may be used to endorse or promote
+products derived from this software without specific prior written
+permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+``AS IS\'\' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n
+
+Opus is subject to the royalty-free patent licenses which are
+specified at:\n\n
+
+Xiph.Org Foundation:
+https://datatracker.ietf.org/ipr/1524/ \n\n
+
+Microsoft Corporation:
+https://datatracker.ietf.org/ipr/1914/ \n\n
+
+Broadcom Corporation:
+https://datatracker.ietf.org/ipr/1526/
+ </string>
+ <string name="license_sirit" translatable="false">Sirit</string>
+ <string name="license_sirit_description" translatable="false">A runtime SPIR-V assembler</string>
+ <string name="license_sirit_link" translatable="false">https://github.com/ReinUsesLisp/sirit</string>
+ <string name="license_sirit_copyright" translatable="false">Copyright © 2019, sirit All rights reserved.</string>
+ <string name="license_sirit_text" translatable="false">
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n
+* Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.\n
+* Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.\n
+* Neither the name of the organization nor the
+ names of its contributors may be used to endorse or promote products
+ derived from this software without specific prior written permission.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ </string>
+ <string name="license_adreno_tools" translatable="false">Adreno Tools</string>
+ <string name="license_adreno_tools_description" translatable="false">A library for applying rootless Adreno GPU driver modifications/replacements</string>
+ <string name="license_adreno_tools_link" translatable="false">https://github.com/bylaws/libadrenotools</string>
+ <string name="license_adreno_tools_copyright" translatable="false">Copyright © 2021, Billy Laws</string>
+ <string name="license_adreno_tools_text" translatable="false">
+BSD 2-Clause License\n\n
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:\n\n
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.\n\n
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.\n\n
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ </string>
+
+</resources>
diff --git a/src/android/app/src/main/res/values/styles.xml b/src/android/app/src/main/res/values/styles.xml
new file mode 100644
index 000000000..4f5de7360
--- /dev/null
+++ b/src/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <!-- Custom button styles -->
+ <style name="InGameMenuOption" parent="Widget.Material3.Button.TextButton">
+ <item name="android:layout_width">match_parent</item>
+ <item name="android:layout_height">48dp</item>
+ <item name="android:textColor">@android:color/black</item>
+ <item name="android:textSize">16sp</item>
+ <item name="android:fontFamily">sans-serif-condensed</item>
+ <item name="android:gravity">center_vertical|left</item>
+ <item name="android:paddingLeft">32dp</item>
+ <item name="android:paddingRight">32dp</item>
+ </style>
+
+ <style name="YuzuSlider" parent="Widget.Material3.Slider">
+ <item name="tickVisible">false</item>
+ <item name="labelBehavior">gone</item>
+ </style>
+
+ <style name="YuzuMaterialDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog">
+ <item name="colorPrimary">@color/yuzu_primaryContainer</item>
+ <item name="colorSurface">@color/yuzu_primaryContainer</item>
+ <item name="colorSecondary">@color/yuzu_primary</item>
+ <item name="android:textColorLink">@color/yuzu_primary</item>
+ <item name="buttonBarPositiveButtonStyle">@style/YuzuButton</item>
+ <item name="buttonBarNegativeButtonStyle">@style/YuzuButton</item>
+ <item name="buttonBarNeutralButtonStyle">@style/YuzuButton</item>
+ </style>
+
+ <style name="YuzuButton" parent="Widget.Material3.Button.TextButton.Dialog">
+ <item name="android:textColor">@color/yuzu_primary</item>
+ <item name="rippleColor">@color/yuzu_inversePrimary</item>
+ </style>
+
+</resources>
diff --git a/src/android/app/src/main/res/values/themes.xml b/src/android/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..60388b71e
--- /dev/null
+++ b/src/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <style name="Theme.Yuzu.Splash.Main" parent="Theme.SplashScreen">
+ <item name="windowSplashScreenBackground">@color/yuzu_surface</item>
+ <item name="windowSplashScreenAnimatedIcon">@drawable/ic_yuzu</item>
+ <item name="postSplashScreenTheme">@style/Theme.Yuzu.Main</item>
+ </style>
+
+ <style name="Theme.Yuzu.Main" parent="Theme.Material3.DayNight.NoActionBar">
+ <item name="colorPrimary">@color/yuzu_primary</item>
+ <item name="colorOnPrimary">@color/yuzu_onPrimary</item>
+ <item name="colorPrimaryContainer">@color/yuzu_primaryContainer</item>
+ <item name="colorOnPrimaryContainer">@color/yuzu_onPrimaryContainer</item>
+ <item name="colorSecondary">@color/yuzu_secondary</item>
+ <item name="colorOnSecondary">@color/yuzu_onSecondary</item>
+ <item name="colorSecondaryContainer">@color/yuzu_secondaryContainer</item>
+ <item name="colorOnSecondaryContainer">@color/yuzu_onSecondaryContainer</item>
+ <item name="colorTertiary">@color/yuzu_tertiary</item>
+ <item name="colorOnTertiary">@color/yuzu_onTertiary</item>
+ <item name="colorTertiaryContainer">@color/yuzu_tertiaryContainer</item>
+ <item name="colorOnTertiaryContainer">@color/yuzu_onTertiaryContainer</item>
+ <item name="colorError">@color/yuzu_error</item>
+ <item name="colorErrorContainer">@color/yuzu_errorContainer</item>
+ <item name="colorOnError">@color/yuzu_onError</item>
+ <item name="colorOnErrorContainer">@color/yuzu_onErrorContainer</item>
+ <item name="android:colorBackground">@color/yuzu_background</item>
+ <item name="colorOnBackground">@color/yuzu_onBackground</item>
+ <item name="colorSurface">@color/yuzu_surface</item>
+ <item name="colorOnSurface">@color/yuzu_onSurface</item>
+ <item name="colorSurfaceVariant">@color/yuzu_surfaceVariant</item>
+ <item name="colorOnSurfaceVariant">@color/yuzu_onSurfaceVariant</item>
+ <item name="colorOutline">@color/yuzu_outline</item>
+ <item name="colorOnSurfaceInverse">@color/yuzu_inverseOnSurface</item>
+ <item name="colorSurfaceInverse">@color/yuzu_inverseSurface</item>
+ <item name="colorPrimaryInverse">@color/yuzu_inversePrimary</item>
+ <item name="android:shadowColor">@color/yuzu_shadow</item>
+
+ <item name="android:statusBarColor">@android:color/transparent</item>
+ <item name="android:navigationBarColor">@android:color/transparent</item>
+
+ <item name="sliderStyle">@style/YuzuSlider</item>
+ <item name="materialAlertDialogTheme">@style/YuzuMaterialDialog</item>
+
+ <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
+
+ <item name="android:enforceStatusBarContrast">false</item>
+ <item name="android:enforceNavigationBarContrast">false</item>
+ </style>
+
+</resources>
diff --git a/src/android/app/src/main/res/values/yuzu_colors.xml b/src/android/app/src/main/res/values/yuzu_colors.xml
new file mode 100644
index 000000000..5b7d189dc
--- /dev/null
+++ b/src/android/app/src/main/res/values/yuzu_colors.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+ <color name="yuzu_primary">#990E00</color>
+ <color name="yuzu_onPrimary">#FFFFFF</color>
+ <color name="yuzu_primaryContainer">#EEDEDD</color>
+ <color name="yuzu_onPrimaryContainer">#400200</color>
+ <color name="yuzu_secondary">#775650</color>
+ <color name="yuzu_onSecondary">#FFFFFF</color>
+ <color name="yuzu_secondaryContainer">#FFDAD4</color>
+ <color name="yuzu_onSecondaryContainer">#2C1511</color>
+ <color name="yuzu_tertiary">#6F5C2E</color>
+ <color name="yuzu_onTertiary">#FFFFFF</color>
+ <color name="yuzu_tertiaryContainer">#FAE0A6</color>
+ <color name="yuzu_onTertiaryContainer">#251A00</color>
+ <color name="yuzu_error">#BA1A1A</color>
+ <color name="yuzu_errorContainer">#FFDAD6</color>
+ <color name="yuzu_onError">#FFFFFF</color>
+ <color name="yuzu_onErrorContainer">#410002</color>
+ <color name="yuzu_background">#FFFBFF</color>
+ <color name="yuzu_onBackground">#201A19</color>
+ <color name="yuzu_surface">#FFFBFF</color>
+ <color name="yuzu_onSurface">#201A19</color>
+ <color name="yuzu_surfaceVariant">#F5DDD9</color>
+ <color name="yuzu_onSurfaceVariant">#534340</color>
+ <color name="yuzu_outline">#857370</color>
+ <color name="yuzu_inverseOnSurface">#FBEEEB</color>
+ <color name="yuzu_inverseSurface">#362F2D</color>
+ <color name="yuzu_inversePrimary">#FFB4A6</color>
+ <color name="yuzu_shadow">#000000</color>
+ <color name="yuzu_surfaceTint">#B52612</color>
+ <color name="yuzu_outlineVariant">#D8C2BE</color>
+
+ <color name="yuzu_ea_background_start">#99FFE1</color>
+ <color name="yuzu_ea_background_end">#76C5FF</color>
+
+</resources>
diff --git a/src/android/app/src/main/res/xml/data_extraction_rules.xml b/src/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..c10efcf56
--- /dev/null
+++ b/src/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<full-backup-content>
+
+ <exclude
+ domain="external"
+ path="./load/" />
+
+ <exclude
+ domain="external"
+ path="./log/" />
+
+ <include
+ domain="external"
+ path="." />
+
+ <include
+ domain="sharedpref"
+ path="." />
+
+</full-backup-content>
diff --git a/src/android/app/src/main/res/xml/data_extraction_rules_api_31.xml b/src/android/app/src/main/res/xml/data_extraction_rules_api_31.xml
new file mode 100644
index 000000000..3ff6cc170
--- /dev/null
+++ b/src/android/app/src/main/res/xml/data_extraction_rules_api_31.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-extraction-rules>
+ <cloud-backup disableIfNoEncryptionCapabilities="false">
+
+ <exclude
+ domain="external"
+ path="./load/" />
+
+ <exclude
+ domain="external"
+ path="./log/" />
+
+ <include
+ domain="external"
+ path="." />
+
+ <include
+ domain="sharedpref"
+ path="." />
+
+ </cloud-backup>
+
+ <device-transfer>
+
+ <exclude
+ domain="external"
+ path="./load/" />
+
+ <exclude
+ domain="external"
+ path="./log/" />
+
+ <include
+ domain="external"
+ path="." />
+
+ <include
+ domain="sharedpref"
+ path="." />
+
+ </device-transfer>
+
+</data-extraction-rules>
diff --git a/src/android/app/src/main/res/xml/nfc_tech_filter.xml b/src/android/app/src/main/res/xml/nfc_tech_filter.xml
new file mode 100644
index 000000000..eb4497446
--- /dev/null
+++ b/src/android/app/src/main/res/xml/nfc_tech_filter.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+ <tech-list>
+ <tech>android.nfc.tech.NfcA</tech>
+ </tech-list>
+</resources>