Quellcode durchsuchen

Merge pull request #10508 from yuzu-emu/lime

Project Lime - yuzu Android Port
bunnei vor 3 Jahren
Ursprung
Commit
cb95d7fe1b
100 geänderte Dateien mit 11018 neuen und 12 gelöschten Zeilen
  1. 15 0
      .ci/scripts/android/build.sh
  2. 27 0
      .ci/scripts/android/upload.sh
  3. 59 0
      .github/workflows/build.yml
  4. 3 0
      .gitmodules
  5. 12 0
      .reuse/dep5
  6. 64 3
      CMakeLists.txt
  7. 5 1
      CMakeModules/DownloadExternals.cmake
  8. 373 0
      LICENSES/MPL-2.0.txt
  9. 6 0
      externals/CMakeLists.txt
  10. 62 8
      externals/ffmpeg/CMakeLists.txt
  11. 1 0
      externals/libadrenotools
  12. 5 0
      src/CMakeLists.txt
  13. 65 0
      src/android/.gitignore
  14. 248 0
      src/android/app/build.gradle.kts
  15. 24 0
      src/android/app/proguard-rules.pro
  16. 22 0
      src/android/app/src/ea/res/drawable/ic_yuzu.xml
  17. 12 0
      src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
  18. 24 0
      src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
  19. 91 0
      src/android/app/src/main/AndroidManifest.xml
  20. 508 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
  21. 61 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
  22. 333 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
  23. 134 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
  24. 69 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
  25. 54 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
  26. 70 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
  27. 121 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt
  28. 100 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt
  29. 48 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt
  30. 31 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt
  31. 101 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt
  32. 302 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
  33. 8 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt
  34. 8 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt
  35. 8 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt
  36. 12 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt
  37. 8 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt
  38. 38 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
  39. 36 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt
  40. 131 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
  41. 37 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt
  42. 158 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
  43. 10 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt
  44. 37 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt
  45. 31 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt
  46. 14 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt
  47. 13 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt
  48. 39 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
  49. 40 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt
  50. 64 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt
  51. 58 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt
  52. 14 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt
  53. 62 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt
  54. 243 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
  55. 84 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt
  56. 57 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt
  57. 340 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
  58. 122 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
  59. 465 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
  60. 58 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt
  61. 48 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
  62. 30 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt
  63. 38 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt
  64. 36 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
  65. 60 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
  66. 39 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
  67. 35 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
  68. 48 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
  69. 241 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
  70. 125 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
  71. 83 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt
  72. 613 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
  73. 330 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
  74. 210 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
  75. 70 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
  76. 59 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt
  77. 137 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt
  78. 62 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
  79. 38 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt
  80. 30 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt
  81. 236 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
  82. 329 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
  83. 86 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt
  84. 61 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt
  85. 41 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
  86. 109 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
  87. 11 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
  88. 36 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
  89. 16 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt
  90. 11 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
  91. 19 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
  92. 47 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
  93. 1064 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
  94. 148 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt
  95. 274 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt
  96. 282 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt
  97. 165 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
  98. 470 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
  99. 11 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
  100. 25 0
      src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt

+ 15 - 0
.ci/scripts/android/build.sh

@@ -0,0 +1,15 @@
+#!/bin/bash -ex
+
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+export NDK_CCACHE="$(which ccache)"
+ccache -s
+
+BUILD_FLAVOR=mainline
+
+cd src/android
+chmod +x ./gradlew
+./gradlew "assemble${BUILD_FLAVOR}Release" "bundle${BUILD_FLAVOR}Release"
+
+ccache -s

+ 27 - 0
.ci/scripts/android/upload.sh

@@ -0,0 +1,27 @@
+#!/bin/bash -ex
+
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+. ./.ci/scripts/common/pre-upload.sh
+
+REV_NAME="yuzu-${GITDATE}-${GITREV}"
+
+BUILD_FLAVOR=mainline
+
+cp src/android/app/build/outputs/apk/"${BUILD_FLAVOR}/release/app-${BUILD_FLAVOR}-release.apk" \
+  "artifacts/${REV_NAME}.apk"
+cp src/android/app/build/outputs/bundle/"${BUILD_FLAVOR}Release"/"app-${BUILD_FLAVOR}-release.aab" \
+  "artifacts/${REV_NAME}.aab"
+
+if [ -n "${ANDROID_KEYSTORE_B64}" ]
+then
+  echo "Signing apk..."
+  base64 --decode <<< "${ANDROID_KEYSTORE_B64}" > ks.jks
+
+  apksigner sign --ks ks.jks \
+    --ks-key-alias "${ANDROID_KEY_ALIAS}" \
+    --ks-pass env:ANDROID_KEYSTORE_PASS "artifacts/${REV_NAME}.apk"
+else
+  echo "No keystore specified, not signing the APK files."
+fi

+ 59 - 0
.github/workflows/verify.yml → .github/workflows/build.yml

@@ -122,3 +122,62 @@ jobs:
         with:
           name: ${{ env.INDIVIDUAL_EXE }}
           path: ${{ env.INDIVIDUAL_EXE }}
+  android:
+    runs-on: ubuntu-latest
+    needs: format
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: recursive
+      - name: Set up cache
+        uses: actions/cache@v3
+        with:
+          path: |
+            ~/.gradle/caches
+            ~/.gradle/wrapper
+            ~/.ccache
+          key: ${{ runner.os }}-android-${{ github.sha }}
+          restore-keys: |
+            ${{ runner.os }}-android-
+      - name: Query tag name
+        uses: olegtarasov/get-tag@v2.1.2
+        id: tagName
+      - name: Install dependencies
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y ccache apksigner glslang-dev glslang-tools
+          git -C ./externals/vcpkg/ fetch --all --unshallow
+      - name: Build
+        run: ./.ci/scripts/android/build.sh
+      - name: Copy and sign artifacts
+        env:
+          ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
+          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+          ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }}
+        run: ./.ci/scripts/android/upload.sh
+      - name: Upload
+        uses: actions/upload-artifact@v3
+        with:
+          name: android
+          path: artifacts/
+  release:
+    runs-on: ubuntu-latest
+    needs: [ android ]
+    if: ${{ startsWith(github.ref, 'refs/tags/') }}
+    steps:
+      - uses: actions/download-artifact@v3
+      - name: Create release
+        uses: actions/create-release@v1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          tag_name: ${{ github.ref_name }}
+          release_name: ${{ github.ref_name }}
+          draft: false
+          prerelease: false
+      - name: Upload artifacts
+        uses: alexellis/upload-assets@0.4.0
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          asset_paths: '["./**/*.tar.*","./**/*.AppImage","./**/*.7z","./**/*.zip","./**/*.apk","./**/*.aab"]'

+ 3 - 0
.gitmodules

@@ -49,3 +49,6 @@
 [submodule "cpp-jwt"]
 	path = externals/cpp-jwt
 	url = https://github.com/arun11299/cpp-jwt.git
+[submodule "externals/libadrenotools"]
+	path = externals/libadrenotools
+	url = https://github.com/bylaws/libadrenotools

+ 12 - 0
.reuse/dep5

@@ -135,3 +135,15 @@ License: GPL-3.0-or-later
 Files: .github/ISSUE_TEMPLATE/*
 Copyright: 2022 yuzu Emulator Project
 License: GPL-2.0-or-later
+
+Files: src/android/app/src/ea/res/*
+Copyright: 2023 yuzu Emulator Project
+License: GPL-3.0-or-later
+
+Files: src/android/app/src/main/res/*
+Copyright: 2023 yuzu Emulator Project
+License: GPL-3.0-or-later
+
+Files: src/android/gradle/wrapper/*
+Copyright: 2023 yuzu Emulator Project
+License: GPL-3.0-or-later

+ 64 - 3
CMakeLists.txt

@@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul
 include(DownloadExternals)
 include(CMakeDependentOption)
 include(CTest)
+include(FetchContent)
 
 # Set bundled sdl2/qt as dependent options.
 # OFF by default, but if ENABLE_SDL2 and MSVC are true then ON
@@ -19,7 +20,7 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON
 # On Linux system SDL2 is likely to be lacking HIDAPI support which have drawbacks but is needed for SDL motion
 CMAKE_DEPENDENT_OPTION(YUZU_USE_EXTERNAL_SDL2 "Compile external SDL2" ON "ENABLE_SDL2;NOT MSVC" OFF)
 
-option(ENABLE_LIBUSB "Enable the use of LibUSB" ON)
+cmake_dependent_option(ENABLE_LIBUSB "Enable the use of LibUSB" ON "NOT ANDROID" OFF)
 
 option(ENABLE_OPENGL "Enable OpenGL" ON)
 mark_as_advanced(FORCE ENABLE_OPENGL)
@@ -48,7 +49,7 @@ option(YUZU_TESTS "Compile tests" "${BUILD_TESTING}")
 
 option(YUZU_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON)
 
-option(YUZU_ROOM "Compile LDN room server" ON)
+cmake_dependent_option(YUZU_ROOM "Compile LDN room server" ON "NOT ANDROID" OFF)
 
 CMAKE_DEPENDENT_OPTION(YUZU_CRASH_DUMPS "Compile Windows crash dump (Minidump) support" OFF "WIN32" OFF)
 
@@ -60,7 +61,67 @@ option(YUZU_ENABLE_LTO "Enable link-time optimization" OFF)
 
 CMAKE_DEPENDENT_OPTION(YUZU_USE_FASTER_LD "Check if a faster linker is available" ON "NOT WIN32" OFF)
 
+# On Android, fetch and compile libcxx before doing anything else
+if (ANDROID)
+    set(CMAKE_SKIP_INSTALL_RULES ON)
+    set(LLVM_VERSION "15.0.6")
+
+    # Note: even though libcxx and libcxxabi have separate releases on the project page,
+    # the separated releases cannot be compiled. Only in-tree builds work. Therefore we
+    # must fetch the source release for the entire llvm tree.
+    FetchContent_Declare(llvm
+        URL "https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/llvm-project-${LLVM_VERSION}.src.tar.xz"
+        URL_HASH SHA256=9d53ad04dc60cb7b30e810faf64c5ab8157dadef46c8766f67f286238256ff92
+        TLS_VERIFY TRUE
+    )
+    FetchContent_MakeAvailable(llvm)
+
+    # libcxx has support for most of the range library, but it's gated behind a flag:
+    add_compile_definitions(_LIBCPP_ENABLE_EXPERIMENTAL)
+
+    # Disable standard header inclusion
+    set(ANDROID_STL "none")
+
+    # libcxxabi
+    set(LIBCXXABI_INCLUDE_TESTS OFF)
+    set(LIBCXXABI_ENABLE_SHARED FALSE)
+    set(LIBCXXABI_ENABLE_STATIC TRUE)
+    set(LIBCXXABI_LIBCXX_INCLUDES "${LIBCXX_TARGET_INCLUDE_DIRECTORY}" CACHE STRING "" FORCE)
+    add_subdirectory("${llvm_SOURCE_DIR}/libcxxabi" "${llvm_BINARY_DIR}/libcxxabi")
+    link_libraries(cxxabi_static)
+
+    # libcxx
+    set(LIBCXX_ABI_NAMESPACE "__ndk1" CACHE STRING "" FORCE)
+    set(LIBCXX_CXX_ABI "libcxxabi")
+    set(LIBCXX_INCLUDE_TESTS OFF)
+    set(LIBCXX_INCLUDE_BENCHMARKS OFF)
+    set(LIBCXX_INCLUDE_DOCS OFF)
+    set(LIBCXX_ENABLE_SHARED FALSE)
+    set(LIBCXX_ENABLE_STATIC TRUE)
+    set(LIBCXX_ENABLE_ASSERTIONS FALSE)
+    add_subdirectory("${llvm_SOURCE_DIR}/libcxx" "${llvm_BINARY_DIR}/libcxx")
+    set_target_properties(cxx-headers PROPERTIES INTERFACE_COMPILE_OPTIONS "-isystem${CMAKE_BINARY_DIR}/${LIBCXX_INSTALL_INCLUDE_DIR}")
+    link_libraries(cxx_static cxx-headers)
+endif()
+
 if (YUZU_USE_BUNDLED_VCPKG)
+    if (ANDROID)
+        set(ENV{ANDROID_NDK_HOME} "${ANDROID_NDK}")
+        list(APPEND VCPKG_MANIFEST_FEATURES "android")
+
+        if (CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a")
+            set(VCPKG_TARGET_TRIPLET "arm64-android")
+            # this is to avoid CMake using the host pkg-config to find the host
+            # libraries when building for Android targets
+            set(PKG_CONFIG_EXECUTABLE "aarch64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE)
+        elseif (CMAKE_ANDROID_ARCH_ABI STREQUAL "x86_64")
+            set(VCPKG_TARGET_TRIPLET "x64-android")
+            set(PKG_CONFIG_EXECUTABLE "x86_64-none-linux-android-pkg-config" CACHE FILEPATH "" FORCE)
+        else()
+            message(FATAL_ERROR "Unsupported Android architecture ${CMAKE_ANDROID_ARCH_ABI}")
+        endif()
+    endif()
+
     if (YUZU_TESTS)
         list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests")
     endif()
@@ -457,7 +518,7 @@ set(FFmpeg_COMPONENTS
     avutil
     swscale)
 
-if (UNIX AND NOT APPLE)
+if (UNIX AND NOT APPLE AND NOT ANDROID)
     find_package(PkgConfig REQUIRED)
     pkg_check_modules(LIBVA libva)
 endif()

+ 5 - 1
CMakeModules/DownloadExternals.cmake

@@ -7,6 +7,7 @@
 #   prefix_var: name of a variable which will be set with the path to the extracted contents
 function(download_bundled_external remote_path lib_name prefix_var)
 
+set(package_base_url "https://github.com/yuzu-emu/")
 set(package_repo "no_platform")
 set(package_extension "no_platform")
 if (WIN32)
@@ -15,10 +16,13 @@ if (WIN32)
 elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
     set(package_repo "ext-linux-bin/raw/main/")
     set(package_extension ".tar.xz")
+elseif (ANDROID)    
+    set(package_repo "ext-android-bin/raw/main/")
+    set(package_extension ".tar.xz")
 else()
     message(FATAL_ERROR "No package available for this platform")
 endif()
-set(package_url "https://github.com/yuzu-emu/${package_repo}")
+set(package_url "${package_base_url}${package_repo}")
 
 set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}")
 if (NOT EXISTS "${prefix}")

+ 373 - 0
LICENSES/MPL-2.0.txt

@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.

+ 6 - 0
externals/CMakeLists.txt

@@ -147,3 +147,9 @@ endif()
 
 add_library(stb stb/stb_dxt.cpp)
 target_include_directories(stb PUBLIC ./stb)
+
+if (ANDROID)
+   if (ARCHITECTURE_arm64)
+       add_subdirectory(libadrenotools)
+   endif()
+endif()

+ 62 - 8
externals/ffmpeg/CMakeLists.txt

@@ -1,7 +1,7 @@
 # SPDX-FileCopyrightText: 2021 yuzu Emulator Project
 # SPDX-License-Identifier: GPL-2.0-or-later
 
-if (NOT WIN32)
+if (NOT WIN32 AND NOT ANDROID)
     # Build FFmpeg from externals
     message(STATUS "Using FFmpeg from externals")
 
@@ -44,10 +44,12 @@ if (NOT WIN32)
     endforeach()
 
     find_package(PkgConfig REQUIRED)
-    pkg_check_modules(LIBVA libva)
-    pkg_check_modules(CUDA cuda)
-    pkg_check_modules(FFNVCODEC ffnvcodec)
-    pkg_check_modules(VDPAU vdpau)
+    if (NOT ANDROID)
+        pkg_check_modules(LIBVA libva)
+        pkg_check_modules(CUDA cuda)
+        pkg_check_modules(FFNVCODEC ffnvcodec)
+        pkg_check_modules(VDPAU vdpau)
+    endif()
 
     set(FFmpeg_HWACCEL_LIBRARIES)
     set(FFmpeg_HWACCEL_FLAGS)
@@ -121,6 +123,26 @@ if (NOT WIN32)
         list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau)
     endif()
 
+    find_program(BASH_PROGRAM bash REQUIRED)
+
+    set(FFmpeg_CROSS_COMPILE_FLAGS "")
+    if (ANDROID)
+        string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME)
+        set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")
+        set(SYSROOT "${TOOLCHAIN}/sysroot")
+        set(FFmpeg_CPU "armv8-a")
+        list(APPEND FFmpeg_CROSS_COMPILE_FLAGS
+            --arch=arm64
+            #--cpu=${FFmpeg_CPU}
+            --enable-cross-compile
+            --cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android-
+            --sysroot=${SYSROOT}
+            --target-os=android
+            --extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld" 
+            --extra-ldflags="-nostdlib"
+        )
+    endif()
+
     # `configure` parameters builds only exactly what yuzu needs from FFmpeg
     # `--disable-vdpau` is needed to avoid linking issues
     set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER}  ${CMAKE_C_COMPILER})
@@ -129,7 +151,7 @@ if (NOT WIN32)
         OUTPUT
             ${FFmpeg_MAKEFILE}
         COMMAND
-            /bin/bash ${FFmpeg_PREFIX}/configure
+            ${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure
                 --disable-avdevice
                 --disable-avformat
                 --disable-doc
@@ -146,12 +168,14 @@ if (NOT WIN32)
                 --cc="${FFmpeg_CC}"
                 --cxx="${FFmpeg_CXX}"
                 ${FFmpeg_HWACCEL_FLAGS}
+                ${FFmpeg_CROSS_COMPILE_FLAGS}
         WORKING_DIRECTORY
             ${FFmpeg_BUILD_DIR}
     )
     unset(FFmpeg_CC)
     unset(FFmpeg_CXX)
     unset(FFmpeg_HWACCEL_FLAGS)
+    unset(FFmpeg_CROSS_COMPILE_FLAGS)
 
     # Workaround for Ubuntu 18.04's older version of make not being able to call make as a child
     # with context of the jobserver. Also helps ninja users.
@@ -197,7 +221,38 @@ if (NOT WIN32)
     else()
         message(FATAL_ERROR "FFmpeg not found")
     endif()
-else(WIN32)
+elseif(ANDROID)
+    # Use yuzu FFmpeg binaries
+    if (ARCHITECTURE_arm64)
+        set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-aarch64")
+    elseif (ARCHITECTURE_x86_64)
+        set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-x86_64")
+    else()
+        message(FATAL_ERROR "Unsupported architecture for Android FFmpeg")
+    endif()
+    set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
+    download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "")
+    set(FFmpeg_FOUND YES)
+    set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
+    set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE)
+    set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
+    set(FFmpeg_LIBRARIES
+        ${FFmpeg_LIBRARY_DIR}/libavcodec.so
+        ${FFmpeg_LIBRARY_DIR}/libavdevice.so
+        ${FFmpeg_LIBRARY_DIR}/libavfilter.so
+        ${FFmpeg_LIBRARY_DIR}/libavformat.so
+        ${FFmpeg_LIBRARY_DIR}/libavutil.so
+        ${FFmpeg_LIBRARY_DIR}/libswresample.so
+        ${FFmpeg_LIBRARY_DIR}/libswscale.so
+        ${FFmpeg_LIBRARY_DIR}/libvpx.a
+        ${FFmpeg_LIBRARY_DIR}/libx264.a
+        CACHE PATH "Paths to FFmpeg libraries" FORCE)
+    # exported variables
+    set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE)
+    set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE)
+    set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE)
+    set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE)
+elseif(WIN32)
     # Use yuzu FFmpeg binaries
     set(FFmpeg_EXT_NAME "ffmpeg-5.1.3")
     set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
@@ -206,7 +261,6 @@ else(WIN32)
     set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
     set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE)
     set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
-    set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE)
     set(FFmpeg_LIBRARIES
     ${FFmpeg_LIBRARY_DIR}/swscale.lib
     ${FFmpeg_LIBRARY_DIR}/avcodec.lib

+ 1 - 0
externals/libadrenotools

@@ -0,0 +1 @@
+Subproject commit 5cd3f5c5ceea6d9e9d435ccdd922d9b99e55d10b

+ 5 - 0
src/CMakeLists.txt

@@ -195,3 +195,8 @@ endif()
 if (ENABLE_WEB_SERVICE)
     add_subdirectory(web_service)
 endif()
+
+if (ANDROID)
+    add_subdirectory(android/app/src/main/jni)
+    target_include_directories(yuzu-android PRIVATE android/app/src/main)
+endif()

+ 65 - 0
src/android/.gitignore

@@ -0,0 +1,65 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Built application files
+*.apk
+*.ap_
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+
+# Keystore files
+# Uncomment the following line if you do not want to check your keystore files in.
+#*.jks
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# CXX compile cache
+app/.cxx
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md

+ 248 - 0
src/android/app/build.gradle.kts

@@ -0,0 +1,248 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import android.annotation.SuppressLint
+
+plugins {
+    id("com.android.application")
+    id("org.jetbrains.kotlin.android")
+    id("kotlin-parcelize")
+    kotlin("plugin.serialization") version "1.8.21"
+}
+
+/**
+ * Use the number of seconds/10 since Jan 1 2016 as the versionCode.
+ * This lets us upload a new build at most every 10 seconds for the
+ * next 680 years.
+ */
+val autoVersion = (((System.currentTimeMillis() / 1000) - 1451606400) / 10).toInt()
+
+@Suppress("UnstableApiUsage")
+android {
+    namespace = "org.yuzu.yuzu_emu"
+
+    compileSdkVersion = "android-33"
+    ndkVersion = "25.2.9519653"
+
+    buildFeatures {
+        viewBinding = true
+    }
+
+    compileOptions {
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
+    }
+
+    kotlinOptions {
+        jvmTarget = "17"
+    }
+
+    packaging {
+        // This is necessary for libadrenotools custom driver loading
+        jniLibs.useLegacyPackaging = true
+    }
+
+    lint {
+        // This is important as it will run lint but not abort on error
+        // Lint has some overly obnoxious "errors" that should really be warnings
+        abortOnError = false
+
+        //Uncomment disable lines for test builds...
+        //disable 'MissingTranslation'bin
+        //disable 'ExtraTranslation'
+    }
+
+    defaultConfig {
+        // TODO If this is ever modified, change application_id in strings.xml
+        applicationId = "org.yuzu.yuzu_emu"
+        minSdk = 30
+        targetSdk = 33
+        versionName = getGitVersion()
+
+        ndk {
+            @SuppressLint("ChromeOsAbiSupport")
+            abiFilters += listOf("arm64-v8a")
+        }
+
+        buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
+        buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
+    }
+
+    // Define build types, which are orthogonal to product flavors.
+    buildTypes {
+
+        // Signed by release key, allowing for upload to Play Store.
+        release {
+            signingConfig = signingConfigs.getByName("debug")
+            isMinifyEnabled = true
+            isDebuggable = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android.txt"),
+                "proguard-rules.pro"
+            )
+        }
+
+        register("relWithVersionCode") {
+            signingConfig = signingConfigs.getByName("debug")
+            isMinifyEnabled = true
+            isDebuggable = false
+            proguardFiles(
+                getDefaultProguardFile("proguard-android.txt"),
+                "proguard-rules.pro"
+            )
+        }
+
+        // builds a release build that doesn't need signing
+        // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
+        register("relWithDebInfo") {
+            signingConfig = signingConfigs.getByName("debug")
+            isMinifyEnabled = true
+            isDebuggable = true
+            proguardFiles(
+                getDefaultProguardFile("proguard-android.txt"),
+                "proguard-rules.pro"
+            )
+            versionNameSuffix = "-debug"
+            isJniDebuggable = true
+        }
+
+        // Signed by debug key disallowing distribution on Play Store.
+        // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
+        debug {
+            isDebuggable = true
+            isJniDebuggable = true
+            versionNameSuffix = "-debug"
+        }
+    }
+
+    flavorDimensions.add("version")
+    productFlavors {
+        create("mainline") {
+            dimension = "version"
+            buildConfigField("Boolean", "PREMIUM", "false")
+        }
+
+        create("ea") {
+            dimension = "version"
+            buildConfigField("Boolean", "PREMIUM", "true")
+            applicationIdSuffix = ".ea"
+        }
+    }
+
+    externalNativeBuild {
+        cmake {
+            version = "3.22.1"
+            path = file("../../../CMakeLists.txt")
+        }
+    }
+
+    defaultConfig {
+        externalNativeBuild {
+            cmake {
+                arguments(
+                    "-DENABLE_QT=0", // Don't use QT
+                    "-DENABLE_SDL2=0", // Don't use SDL
+                    "-DENABLE_WEB_SERVICE=0", // Don't use telemetry
+                    "-DBUNDLE_SPEEX=ON",
+                    "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work
+                    "-DYUZU_USE_BUNDLED_VCPKG=ON",
+                    "-DYUZU_USE_BUNDLED_FFMPEG=ON",
+                    "-DYUZU_ENABLE_LTO=ON"
+                )
+
+                abiFilters("arm64-v8a", "x86_64")
+            }
+        }
+    }
+}
+
+dependencies {
+    implementation("androidx.core:core-ktx:1.10.1")
+    implementation("androidx.appcompat:appcompat:1.6.1")
+    implementation("androidx.recyclerview:recyclerview:1.3.0")
+    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+    implementation("androidx.fragment:fragment-ktx:1.5.7")
+    implementation("androidx.documentfile:documentfile:1.0.1")
+    implementation("com.google.android.material:material:1.9.0")
+    implementation("androidx.preference:preference:1.2.0")
+    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
+    implementation("io.coil-kt:coil:2.2.2")
+    implementation("androidx.core:core-splashscreen:1.0.1")
+    implementation("androidx.window:window:1.0.0")
+    implementation("org.ini4j:ini4j:0.5.4")
+    implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+    implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+    implementation("androidx.navigation:navigation-fragment-ktx:2.5.3")
+    implementation("androidx.navigation:navigation-ui-ktx:2.5.3")
+    implementation("info.debatty:java-string-similarity:2.0.0")
+    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
+}
+
+fun getGitVersion(): String {
+    var versionName = "0.0"
+
+    try {
+        versionName = ProcessBuilder("git", "describe", "--always", "--long")
+            .directory(project.rootDir)
+            .redirectOutput(ProcessBuilder.Redirect.PIPE)
+            .redirectError(ProcessBuilder.Redirect.PIPE)
+            .start().inputStream.bufferedReader().use { it.readText() }
+            .trim()
+            .replace(Regex("(-0)?-[^-]+$"), "")
+    } catch (e: Exception) {
+        logger.error("Cannot find git, defaulting to dummy version number")
+    }
+
+    if (System.getenv("GITHUB_ACTIONS") != null) {
+        val gitTag = System.getenv("GIT_TAG_NAME")
+        versionName = gitTag ?: versionName
+    }
+
+    return versionName
+}
+
+fun getGitHash(): String {
+    try {
+        val processBuilder = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
+        processBuilder.directory(project.rootDir)
+        val process = processBuilder.start()
+        val inputStream = process.inputStream
+        val errorStream = process.errorStream
+        process.waitFor()
+
+        return if (process.exitValue() == 0) {
+            inputStream.bufferedReader()
+                .use { it.readText().trim() } // return the value of gitHash
+        } else {
+            val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
+            logger.error("Error running git command: $errorMessage")
+            "dummy-hash" // return a dummy hash value in case of an error
+        }
+    } catch (e: Exception) {
+        logger.error("$e: Cannot find git, defaulting to dummy build hash")
+        return "dummy-hash" // return a dummy hash value in case of an error
+    }
+}
+
+fun getBranch(): String {
+    try {
+        val processBuilder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
+        processBuilder.directory(project.rootDir)
+        val process = processBuilder.start()
+        val inputStream = process.inputStream
+        val errorStream = process.errorStream
+        process.waitFor()
+
+        return if (process.exitValue() == 0) {
+            inputStream.bufferedReader()
+                .use { it.readText().trim() } // return the value of gitHash
+        } else {
+            val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
+            logger.error("Error running git command: $errorMessage")
+            "dummy-hash" // return a dummy hash value in case of an error
+        }
+    } catch (e: Exception) {
+        logger.error("$e: Cannot find git, defaulting to dummy build hash")
+        return "dummy-hash" // return a dummy hash value in case of an error
+    }
+}

+ 24 - 0
src/android/app/proguard-rules.pro

@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# To get usable stack traces
+-dontobfuscate
+
+# Prevents crashing when using Wini
+-keep class org.ini4j.spi.IniParser
+-keep class org.ini4j.spi.IniBuilder
+-keep class org.ini4j.spi.IniFormatter
+
+# Suppress warnings for R8
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
+-dontwarn java.beans.Introspector
+-dontwarn java.beans.VetoableChangeListener
+-dontwarn java.beans.VetoableChangeSupport

+ 22 - 0
src/android/app/src/ea/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="#C6C6C6"
+        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="#FFDC00"
+        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>

+ 12 - 0
src/android/app/src/ea/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="#C6C6C6"
+        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="#FFDC00"
+        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>

+ 24 - 0
src/android/app/src/ea/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="#C6C6C6"
+        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="#FFDC00"
+        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>

+ 91 - 0
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>

+ 508 - 0
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
+    }
+}

+ 61 - 0
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
+    }
+}

+ 333 - 0
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())
+        }
+    }
+}

+ 134 - 0
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()
+        )
+    }
+}

+ 69 - 0
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
+                    )
+            }
+        }
+    }
+}

+ 54 - 0
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)
+        }
+    }
+}

+ 70 - 0
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()
+                }
+            }
+        }
+    }
+}

+ 121 - 0
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)
+}

+ 100 - 0
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
+        }
+    }
+}

+ 48 - 0
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
+    }
+}

+ 31 - 0
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)
+    }
+}

+ 101 - 0
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
+        }
+    }
+}

+ 302 - 0
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)
+    }
+}

+ 8 - 0
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
+}

+ 8 - 0
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
+}

+ 8 - 0
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
+}

+ 12 - 0
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
+}

+ 8 - 0
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
+}

+ 38 - 0
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 }
+    }
+}

+ 36 - 0
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 }
+    }
+}

+ 131 - 0
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 }
+    }
+}

+ 37 - 0
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)
+        }
+    }
+}

+ 158 - 0
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
+                )
+        }
+    }
+}

+ 10 - 0
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()
+}

+ 37 - 0
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 }
+    }
+}

+ 31 - 0
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
+    }
+}

+ 14 - 0
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
+}

+ 13 - 0
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
+}

+ 39 - 0
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
+    }
+}

+ 40 - 0
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
+    }
+}

+ 64 - 0
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
+    }
+}

+ 58 - 0
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
+    }
+}

+ 14 - 0
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
+}

+ 62 - 0
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
+    }
+}

+ 243 - 0
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)
+        }
+    }
+}

+ 84 - 0
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"
+    }
+}

+ 57 - 0
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()
+}

+ 340 - 0
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
+    }
+}

+ 122 - 0
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
+        }
+    }
+}

+ 465 - 0
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
+                )
+            )
+        }
+    }
+}

+ 58 - 0
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()
+}

+ 48 - 0
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
+    }
+}

+ 30 - 0
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
+    }
+}

+ 38 - 0
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
+    }
+}

+ 36 - 0
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
+}

+ 60 - 0
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
+    }
+}

+ 39 - 0
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
+    }
+}

+ 35 - 0
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
+    }
+}

+ 48 - 0
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
+    }
+}

+ 241 - 0
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)
+        }
+    }
+}

+ 125 - 0
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
+        }
+}

+ 83 - 0
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
+        }
+}

+ 613 - 0
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
+        }
+    }
+}

+ 330 - 0
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
+        }
+}

+ 210 - 0
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"
+    }
+}

+ 70 - 0
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
+        }
+    }
+}

+ 59 - 0
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
+        }
+    }
+}

+ 137 - 0
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
+        }
+}

+ 62 - 0
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
+        }
+    }
+}

+ 38 - 0
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"
+    }
+}

+ 30 - 0
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"
+    }
+}

+ 236 - 0
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
+        }
+}

+ 329 - 0
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
+        }
+}

+ 86 - 0
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
+        }
+    }
+}

+ 61 - 0
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)
+    }
+}

+ 41 - 0
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")
+        )
+    }
+}

+ 109 - 0
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)
+                }
+            }
+        }
+    }
+}

+ 11 - 0
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
+)

+ 36 - 0
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
+    }
+}

+ 16 - 0
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

+ 11 - 0
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
+}

+ 19 - 0
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 }
+)

+ 47 - 0
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)
+        }
+    }
+}

+ 1064 - 0
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
+        }
+    }
+}

+ 148 - 0
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
+}

+ 274 - 0
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
+    }
+}

+ 282 - 0
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
+        }
+    }
+}

+ 165 - 0
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
+        }
+}

+ 470 - 0
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()
+                        }
+                    }
+                }
+            }
+        }
+}

+ 11 - 0
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
+}

+ 25 - 0
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]
+    }
+}

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.