diff --git a/.ci/scripts/android/build.sh b/.ci/scripts/android/build.sh
new file mode 100755
index 000000000..a5fd1ee18
--- /dev/null
+++ b/.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
diff --git a/.ci/scripts/android/upload.sh b/.ci/scripts/android/upload.sh
new file mode 100755
index 000000000..cfaeff328
--- /dev/null
+++ b/.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
diff --git a/.github/workflows/verify.yml b/.github/workflows/build.yml
similarity index 66%
rename from .github/workflows/verify.yml
rename to .github/workflows/build.yml
index 7cde8380b..9875de206 100644
--- a/.github/workflows/verify.yml
+++ b/.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"]'
diff --git a/.gitmodules b/.gitmodules
index 75c7b5fe0..ad7a9b970 100644
--- a/.gitmodules
+++ b/.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
diff --git a/.reuse/dep5 b/.reuse/dep5
index 3810f2c41..31178fc4c 100644
--- a/.reuse/dep5
+++ b/.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
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7276ac9dd..30a3c939e 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul
include(DownloadExternals)
include(CMakeDependentOption)
include(CTest)
+include(FetchContent)
# Set bundled sdl2/qt as dependent options.
# OFF by default, but if ENABLE_SDL2 and MSVC are true then ON
@@ -19,7 +20,7 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON
# On Linux system SDL2 is likely to be lacking HIDAPI support which have drawbacks but is needed for SDL motion
CMAKE_DEPENDENT_OPTION(YUZU_USE_EXTERNAL_SDL2 "Compile external SDL2" ON "ENABLE_SDL2;NOT MSVC" OFF)
-option(ENABLE_LIBUSB "Enable the use of LibUSB" ON)
+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()
diff --git a/CMakeModules/DownloadExternals.cmake b/CMakeModules/DownloadExternals.cmake
index 8fe5ba48d..972f5ca74 100644
--- a/CMakeModules/DownloadExternals.cmake
+++ b/CMakeModules/DownloadExternals.cmake
@@ -7,6 +7,7 @@
# prefix_var: name of a variable which will be set with the path to the extracted contents
function(download_bundled_external remote_path lib_name prefix_var)
+set(package_base_url "https://github.com/yuzu-emu/")
set(package_repo "no_platform")
set(package_extension "no_platform")
if (WIN32)
@@ -15,10 +16,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}")
diff --git a/LICENSES/MPL-2.0.txt b/LICENSES/MPL-2.0.txt
new file mode 100644
index 000000000..14e2f777f
--- /dev/null
+++ b/LICENSES/MPL-2.0.txt
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
diff --git a/externals/CMakeLists.txt b/externals/CMakeLists.txt
index d78d10147..500eb21e3 100644
--- a/externals/CMakeLists.txt
+++ b/externals/CMakeLists.txt
@@ -147,3 +147,9 @@ endif()
add_library(stb stb/stb_dxt.cpp)
target_include_directories(stb PUBLIC ./stb)
+
+if (ANDROID)
+ if (ARCHITECTURE_arm64)
+ add_subdirectory(libadrenotools)
+ endif()
+endif()
diff --git a/externals/ffmpeg/CMakeLists.txt b/externals/ffmpeg/CMakeLists.txt
index 03fad0778..0a926e399 100644
--- a/externals/ffmpeg/CMakeLists.txt
+++ b/externals/ffmpeg/CMakeLists.txt
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2021 yuzu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
-if (NOT WIN32)
+if (NOT WIN32 AND NOT ANDROID)
# Build FFmpeg from externals
message(STATUS "Using FFmpeg from externals")
@@ -44,10 +44,12 @@ if (NOT WIN32)
endforeach()
find_package(PkgConfig REQUIRED)
- pkg_check_modules(LIBVA libva)
- pkg_check_modules(CUDA cuda)
- pkg_check_modules(FFNVCODEC ffnvcodec)
- pkg_check_modules(VDPAU vdpau)
+ if (NOT ANDROID)
+ pkg_check_modules(LIBVA libva)
+ pkg_check_modules(CUDA cuda)
+ pkg_check_modules(FFNVCODEC ffnvcodec)
+ pkg_check_modules(VDPAU vdpau)
+ endif()
set(FFmpeg_HWACCEL_LIBRARIES)
set(FFmpeg_HWACCEL_FLAGS)
@@ -121,6 +123,26 @@ if (NOT WIN32)
list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau)
endif()
+ find_program(BASH_PROGRAM bash REQUIRED)
+
+ set(FFmpeg_CROSS_COMPILE_FLAGS "")
+ if (ANDROID)
+ string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME)
+ set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}")
+ set(SYSROOT "${TOOLCHAIN}/sysroot")
+ set(FFmpeg_CPU "armv8-a")
+ list(APPEND FFmpeg_CROSS_COMPILE_FLAGS
+ --arch=arm64
+ #--cpu=${FFmpeg_CPU}
+ --enable-cross-compile
+ --cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android-
+ --sysroot=${SYSROOT}
+ --target-os=android
+ --extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld"
+ --extra-ldflags="-nostdlib"
+ )
+ endif()
+
# `configure` parameters builds only exactly what yuzu needs from FFmpeg
# `--disable-vdpau` is needed to avoid linking issues
set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER})
@@ -129,7 +151,7 @@ if (NOT WIN32)
OUTPUT
${FFmpeg_MAKEFILE}
COMMAND
- /bin/bash ${FFmpeg_PREFIX}/configure
+ ${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure
--disable-avdevice
--disable-avformat
--disable-doc
@@ -146,12 +168,14 @@ if (NOT WIN32)
--cc="${FFmpeg_CC}"
--cxx="${FFmpeg_CXX}"
${FFmpeg_HWACCEL_FLAGS}
+ ${FFmpeg_CROSS_COMPILE_FLAGS}
WORKING_DIRECTORY
${FFmpeg_BUILD_DIR}
)
unset(FFmpeg_CC)
unset(FFmpeg_CXX)
unset(FFmpeg_HWACCEL_FLAGS)
+ unset(FFmpeg_CROSS_COMPILE_FLAGS)
# Workaround for Ubuntu 18.04's older version of make not being able to call make as a child
# with context of the jobserver. Also helps ninja users.
@@ -197,7 +221,38 @@ if (NOT WIN32)
else()
message(FATAL_ERROR "FFmpeg not found")
endif()
-else(WIN32)
+elseif(ANDROID)
+ # Use yuzu FFmpeg binaries
+ if (ARCHITECTURE_arm64)
+ set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-aarch64")
+ elseif (ARCHITECTURE_x86_64)
+ set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-x86_64")
+ else()
+ message(FATAL_ERROR "Unsupported architecture for Android FFmpeg")
+ endif()
+ set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
+ download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "")
+ set(FFmpeg_FOUND YES)
+ set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
+ set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE)
+ set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
+ set(FFmpeg_LIBRARIES
+ ${FFmpeg_LIBRARY_DIR}/libavcodec.so
+ ${FFmpeg_LIBRARY_DIR}/libavdevice.so
+ ${FFmpeg_LIBRARY_DIR}/libavfilter.so
+ ${FFmpeg_LIBRARY_DIR}/libavformat.so
+ ${FFmpeg_LIBRARY_DIR}/libavutil.so
+ ${FFmpeg_LIBRARY_DIR}/libswresample.so
+ ${FFmpeg_LIBRARY_DIR}/libswscale.so
+ ${FFmpeg_LIBRARY_DIR}/libvpx.a
+ ${FFmpeg_LIBRARY_DIR}/libx264.a
+ CACHE PATH "Paths to FFmpeg libraries" FORCE)
+ # exported variables
+ set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE)
+ set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE)
+ set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE)
+ set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE)
+elseif(WIN32)
# Use yuzu FFmpeg binaries
set(FFmpeg_EXT_NAME "ffmpeg-5.1.3")
set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
@@ -206,7 +261,6 @@ else(WIN32)
set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE)
set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE)
set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE)
- set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE)
set(FFmpeg_LIBRARIES
${FFmpeg_LIBRARY_DIR}/swscale.lib
${FFmpeg_LIBRARY_DIR}/avcodec.lib
diff --git a/externals/libadrenotools b/externals/libadrenotools
new file mode 160000
index 000000000..5cd3f5c5c
--- /dev/null
+++ b/externals/libadrenotools
@@ -0,0 +1 @@
+Subproject commit 5cd3f5c5ceea6d9e9d435ccdd922d9b99e55d10b
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 5e3a74c0f..55b113297 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -195,3 +195,8 @@ endif()
if (ENABLE_WEB_SERVICE)
add_subdirectory(web_service)
endif()
+
+if (ANDROID)
+ add_subdirectory(android/app/src/main/jni)
+ target_include_directories(yuzu-android PRIVATE android/app/src/main)
+endif()
diff --git a/src/android/.gitignore b/src/android/.gitignore
new file mode 100644
index 000000000..121cc8484
--- /dev/null
+++ b/src/android/.gitignore
@@ -0,0 +1,65 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# Built application files
+*.apk
+*.ap_
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
+# IntelliJ
+*.iml
+.idea/
+
+# Keystore files
+# Uncomment the following line if you do not want to check your keystore files in.
+#*.jks
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+
+# CXX compile cache
+app/.cxx
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
diff --git a/src/android/app/build.gradle.kts b/src/android/app/build.gradle.kts
new file mode 100644
index 000000000..06f22fabe
--- /dev/null
+++ b/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
+ }
+}
diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro
new file mode 100644
index 000000000..691e08fd0
--- /dev/null
+++ b/src/android/app/proguard-rules.pro
@@ -0,0 +1,24 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# To get usable stack traces
+-dontobfuscate
+
+# Prevents crashing when using Wini
+-keep class org.ini4j.spi.IniParser
+-keep class org.ini4j.spi.IniBuilder
+-keep class org.ini4j.spi.IniFormatter
+
+# Suppress warnings for R8
+-dontwarn org.bouncycastle.jsse.BCSSLParameters
+-dontwarn org.bouncycastle.jsse.BCSSLSocket
+-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
+-dontwarn org.conscrypt.Conscrypt$Version
+-dontwarn org.conscrypt.Conscrypt
+-dontwarn org.conscrypt.ConscryptHostnameVerifier
+-dontwarn org.openjsse.javax.net.ssl.SSLParameters
+-dontwarn org.openjsse.javax.net.ssl.SSLSocket
+-dontwarn org.openjsse.net.ssl.OpenJSSE
+-dontwarn java.beans.Introspector
+-dontwarn java.beans.VetoableChangeListener
+-dontwarn java.beans.VetoableChangeSupport
diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu.xml b/src/android/app/src/ea/res/drawable/ic_yuzu.xml
new file mode 100644
index 000000000..deb8ba53f
--- /dev/null
+++ b/src/android/app/src/ea/res/drawable/ic_yuzu.xml
@@ -0,0 +1,22 @@
+
+
+
+
diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
new file mode 100644
index 000000000..4ef472876
--- /dev/null
+++ b/src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml b/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
new file mode 100644
index 000000000..29d0cfced
--- /dev/null
+++ b/src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..43087f2c0
--- /dev/null
+++ b/src/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
new file mode 100644
index 000000000..c11b6bc16
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
@@ -0,0 +1,508 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.Surface
+import android.view.View
+import android.widget.TextView
+import androidx.annotation.Keep
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath
+import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize
+import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri
+import org.yuzu.yuzu_emu.utils.Log.error
+import org.yuzu.yuzu_emu.utils.Log.verbose
+import org.yuzu.yuzu_emu.utils.Log.warning
+import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
+import java.lang.ref.WeakReference
+
+/**
+ * Class which contains methods that interact
+ * with the native side of the Yuzu code.
+ */
+object NativeLibrary {
+ /**
+ * Default controller id for each device
+ */
+ const val Player1Device = 0
+ const val Player2Device = 1
+ const val Player3Device = 2
+ const val Player4Device = 3
+ const val Player5Device = 4
+ const val Player6Device = 5
+ const val Player7Device = 6
+ const val Player8Device = 7
+ const val ConsoleDevice = 8
+
+ /**
+ * Controller type for each device
+ */
+ const val ProController = 3
+ const val Handheld = 4
+ const val JoyconDual = 5
+ const val JoyconLeft = 6
+ const val JoyconRight = 7
+ const val GameCube = 8
+ const val Pokeball = 9
+ const val NES = 10
+ const val SNES = 11
+ const val N64 = 12
+ const val SegaGenesis = 13
+
+ @JvmField
+ var sEmulationActivity = WeakReference(null)
+
+ init {
+ try {
+ System.loadLibrary("yuzu-android")
+ } catch (ex: UnsatisfiedLinkError) {
+ error("[NativeLibrary] $ex")
+ }
+ }
+
+ @Keep
+ @JvmStatic
+ fun openContentUri(path: String?, openmode: String?): Int {
+ return if (isNativePath(path!!)) {
+ YuzuApplication.documentsTree!!.openContentUri(path, openmode)
+ } else openContentUri(appContext, path, openmode)
+ }
+
+ @Keep
+ @JvmStatic
+ fun getSize(path: String?): Long {
+ return if (isNativePath(path!!)) {
+ YuzuApplication.documentsTree!!.getFileSize(path)
+ } else getFileSize(appContext, path)
+ }
+
+ /**
+ * Returns true if pro controller isn't available and handheld is
+ */
+ external fun isHandheldOnly(): Boolean
+
+ /**
+ * Changes controller type for a specific device.
+ *
+ * @param Device The input descriptor of the gamepad.
+ * @param Type The NpadStyleIndex of the gamepad.
+ */
+ external fun setDeviceType(Device: Int, Type: Int): Boolean
+
+ /**
+ * Handles event when a gamepad is connected.
+ *
+ * @param Device The input descriptor of the gamepad.
+ */
+ external fun onGamePadConnectEvent(Device: Int): Boolean
+
+ /**
+ * Handles event when a gamepad is disconnected.
+ *
+ * @param Device The input descriptor of the gamepad.
+ */
+ external fun onGamePadDisconnectEvent(Device: Int): Boolean
+
+ /**
+ * Handles button press events for a gamepad.
+ *
+ * @param Device The input descriptor of the gamepad.
+ * @param Button Key code identifying which button was pressed.
+ * @param Action Mask identifying which action is happening (button pressed down, or button released).
+ * @return If we handled the button press.
+ */
+ external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean
+
+ /**
+ * Handles joystick movement events.
+ *
+ * @param Device The device ID of the gamepad.
+ * @param Axis The axis ID
+ * @param x_axis The value of the x-axis represented by the given ID.
+ * @param y_axis The value of the y-axis represented by the given ID.
+ */
+ external fun onGamePadJoystickEvent(
+ Device: Int,
+ Axis: Int,
+ x_axis: Float,
+ y_axis: Float
+ ): Boolean
+
+ /**
+ * Handles motion events.
+ *
+ * @param delta_timestamp The finger id corresponding to this event
+ * @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor.
+ * @param accel_x,accel_y,accel_z The value of the y-axis
+ */
+ external fun onGamePadMotionEvent(
+ Device: Int,
+ delta_timestamp: Long,
+ gyro_x: Float,
+ gyro_y: Float,
+ gyro_z: Float,
+ accel_x: Float,
+ accel_y: Float,
+ accel_z: Float
+ ): Boolean
+
+ /**
+ * Signals and load a nfc tag
+ *
+ * @param data Byte array containing all the data from a nfc tag
+ */
+ external fun onReadNfcTag(data: ByteArray?): Boolean
+
+ /**
+ * Removes current loaded nfc tag
+ */
+ external fun onRemoveNfcTag(): Boolean
+
+ /**
+ * Handles touch press events.
+ *
+ * @param finger_id The finger id corresponding to this event
+ * @param x_axis The value of the x-axis.
+ * @param y_axis The value of the y-axis.
+ */
+ external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float)
+
+ /**
+ * Handles touch movement.
+ *
+ * @param x_axis The value of the instantaneous x-axis.
+ * @param y_axis The value of the instantaneous y-axis.
+ */
+ external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float)
+
+ /**
+ * Handles touch release events.
+ *
+ * @param finger_id The finger id corresponding to this event
+ */
+ external fun onTouchReleased(finger_id: Int)
+
+ external fun reloadSettings()
+
+ external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String?
+
+ external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?)
+
+ external fun initGameIni(gameID: String?)
+
+ /**
+ * Gets the embedded icon within the given ROM.
+ *
+ * @param filename the file path to the ROM.
+ * @return a byte array containing the JPEG data for the icon.
+ */
+ external fun getIcon(filename: String): ByteArray
+
+ /**
+ * Gets the embedded title of the given ISO/ROM.
+ *
+ * @param filename The file path to the ISO/ROM.
+ * @return the embedded title of the ISO/ROM.
+ */
+ external fun getTitle(filename: String): String
+
+ external fun getDescription(filename: String): String
+
+ external fun getGameId(filename: String): String
+
+ external fun getRegions(filename: String): String
+
+ external fun getCompany(filename: String): String
+
+ external fun setAppDirectory(directory: String)
+
+ external fun initializeGpuDriver(
+ hookLibDir: String?,
+ customDriverDir: String?,
+ customDriverName: String?,
+ fileRedirectDir: String?
+ )
+
+ external fun reloadKeys(): Boolean
+
+ external fun initializeEmulation()
+
+ external fun defaultCPUCore(): Int
+
+ /**
+ * Begins emulation.
+ */
+ external fun run(path: String?)
+
+ /**
+ * Begins emulation from the specified savestate.
+ */
+ external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
+
+ // Surface Handling
+ external fun surfaceChanged(surf: Surface?)
+
+ external fun surfaceDestroyed()
+
+ /**
+ * Unpauses emulation from a paused state.
+ */
+ external fun unPauseEmulation()
+
+ /**
+ * Pauses emulation.
+ */
+ external fun pauseEmulation()
+
+ /**
+ * Stops emulation.
+ */
+ external fun stopEmulation()
+
+ /**
+ * Resets the in-memory ROM metadata cache.
+ */
+ external fun resetRomMetadata()
+
+ /**
+ * Returns true if emulation is running (or is paused).
+ */
+ external fun isRunning(): Boolean
+
+ /**
+ * Returns the performance stats for the current game
+ */
+ external fun getPerfStats(): DoubleArray
+
+ /**
+ * Notifies the core emulation that the orientation has changed.
+ */
+ external fun notifyOrientationChange(layout_option: Int, rotation: Int)
+
+ enum class CoreError {
+ ErrorSystemFiles,
+ ErrorSavestate,
+ ErrorUnknown
+ }
+
+ private var coreErrorAlertResult = false
+ private val coreErrorAlertLock = Object()
+
+ class CoreErrorDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val title = requireArguments().serializable("title")
+ val message = requireArguments().serializable("message")
+
+ return MaterialAlertDialogBuilder(requireActivity())
+ .setTitle(title)
+ .setMessage(message)
+ .setPositiveButton(R.string.continue_button, null)
+ .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
+ coreErrorAlertResult = false
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
+ }
+ .create()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ coreErrorAlertResult = true
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
+ }
+
+ companion object {
+ fun newInstance(title: String?, message: String?): CoreErrorDialogFragment {
+ val frag = CoreErrorDialogFragment()
+ val args = Bundle()
+ args.putString("title", title)
+ args.putString("message", message)
+ frag.arguments = args
+ return frag
+ }
+ }
+ }
+
+ private fun onCoreErrorImpl(title: String, message: String) {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ error("[NativeLibrary] EmulationActivity not present")
+ return
+ }
+
+ val fragment = CoreErrorDialogFragment.newInstance(title, message)
+ fragment.show(emulationActivity.supportFragmentManager, "coreError")
+ }
+
+ /**
+ * Handles a core error.
+ *
+ * @return true: continue; false: abort
+ */
+ fun onCoreError(error: CoreError?, details: String): Boolean {
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ error("[NativeLibrary] EmulationActivity not present")
+ return false
+ }
+
+ val title: String
+ val message: String
+ when (error) {
+ CoreError.ErrorSystemFiles -> {
+ title = emulationActivity.getString(R.string.system_archive_not_found)
+ message = emulationActivity.getString(
+ R.string.system_archive_not_found_message,
+ details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) }
+ )
+ }
+ CoreError.ErrorSavestate -> {
+ title = emulationActivity.getString(R.string.save_load_error)
+ message = details
+ }
+ CoreError.ErrorUnknown -> {
+ title = emulationActivity.getString(R.string.fatal_error)
+ message = emulationActivity.getString(R.string.fatal_error_message)
+ }
+ else -> {
+ return true
+ }
+ }
+
+ // Show the AlertDialog on the main thread.
+ emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) })
+
+ // Wait for the lock to notify that it is complete.
+ synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() }
+
+ return coreErrorAlertResult
+ }
+
+ @Keep
+ @JvmStatic
+ fun exitEmulationActivity(resultCode: Int) {
+ val Success = 0
+ val ErrorNotInitialized = 1
+ val ErrorGetLoader = 2
+ val ErrorSystemFiles = 3
+ val ErrorSharedFont = 4
+ val ErrorVideoCore = 5
+ val ErrorUnknown = 6
+ val ErrorLoader = 7
+
+ val captionId: Int
+ var descriptionId: Int
+ when (resultCode) {
+ ErrorVideoCore -> {
+ captionId = R.string.loader_error_video_core
+ descriptionId = R.string.loader_error_video_core_description
+ }
+ else -> {
+ captionId = R.string.loader_error_encrypted
+ descriptionId = R.string.loader_error_encrypted_roms_description
+ if (!reloadKeys()) {
+ descriptionId = R.string.loader_error_encrypted_keys_description
+ }
+ }
+ }
+
+ val emulationActivity = sEmulationActivity.get()
+ if (emulationActivity == null) {
+ warning("[NativeLibrary] EmulationActivity is null, can't exit.")
+ return
+ }
+
+ val builder = MaterialAlertDialogBuilder(emulationActivity)
+ .setTitle(captionId)
+ .setMessage(
+ Html.fromHtml(
+ emulationActivity.getString(descriptionId),
+ Html.FROM_HTML_MODE_LEGACY
+ )
+ )
+ .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() }
+ .setOnDismissListener { emulationActivity.finish() }
+ emulationActivity.runOnUiThread {
+ val alert = builder.create()
+ alert.show()
+ (alert.findViewById(android.R.id.message) as TextView).movementMethod =
+ LinkMovementMethod.getInstance()
+ }
+ }
+
+ fun setEmulationActivity(emulationActivity: EmulationActivity?) {
+ verbose("[NativeLibrary] Registering EmulationActivity.")
+ sEmulationActivity = WeakReference(emulationActivity)
+ }
+
+ fun clearEmulationActivity() {
+ verbose("[NativeLibrary] Unregistering EmulationActivity.")
+ sEmulationActivity.clear()
+ }
+
+ /**
+ * Logs the Yuzu version, Android version and, CPU.
+ */
+ external fun logDeviceInfo()
+
+ /**
+ * Submits inline keyboard text. Called on input for buttons that result text.
+ * @param text Text to submit to the inline software keyboard implementation.
+ */
+ external fun submitInlineKeyboardText(text: String?)
+
+ /**
+ * Submits inline keyboard input. Used to indicate keys pressed that are not text.
+ * @param key_code Android Key Code associated with the keyboard input.
+ */
+ external fun submitInlineKeyboardInput(key_code: Int)
+
+ /**
+ * Button type for use in onTouchEvent
+ */
+ object ButtonType {
+ const val BUTTON_A = 0
+ const val BUTTON_B = 1
+ const val BUTTON_X = 2
+ const val BUTTON_Y = 3
+ const val STICK_L = 4
+ const val STICK_R = 5
+ const val TRIGGER_L = 6
+ const val TRIGGER_R = 7
+ const val TRIGGER_ZL = 8
+ const val TRIGGER_ZR = 9
+ const val BUTTON_PLUS = 10
+ const val BUTTON_MINUS = 11
+ const val DPAD_LEFT = 12
+ const val DPAD_UP = 13
+ const val DPAD_RIGHT = 14
+ const val DPAD_DOWN = 15
+ const val BUTTON_SL = 16
+ const val BUTTON_SR = 17
+ const val BUTTON_HOME = 18
+ const val BUTTON_CAPTURE = 19
+ }
+
+ /**
+ * Stick type for use in onTouchEvent
+ */
+ object StickType {
+ const val STICK_L = 0
+ const val STICK_R = 1
+ }
+
+ /**
+ * Button states
+ */
+ object ButtonState {
+ const val RELEASED = 0
+ const val PRESSED = 1
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
new file mode 100644
index 000000000..4c947b786
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.DocumentsTree
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+import java.io.File
+
+fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir
+
+class YuzuApplication : Application() {
+ private fun createNotificationChannels() {
+ val emulationChannel = NotificationChannel(
+ getString(R.string.emulation_notification_channel_id),
+ getString(R.string.emulation_notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ emulationChannel.description = getString(R.string.emulation_notification_channel_description)
+ emulationChannel.setSound(null, null)
+ emulationChannel.vibrationPattern = null
+
+ val noticeChannel = NotificationChannel(
+ getString(R.string.notice_notification_channel_id),
+ getString(R.string.notice_notification_channel_name),
+ NotificationManager.IMPORTANCE_HIGH
+ )
+ noticeChannel.description = getString(R.string.notice_notification_channel_description)
+ noticeChannel.setSound(null, null)
+
+ // Register the channel with the system; you can't change the importance
+ // or other notification behaviors after this
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(emulationChannel)
+ notificationManager.createNotificationChannel(noticeChannel)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ application = this
+ documentsTree = DocumentsTree()
+ DirectoryInitialization.start(applicationContext)
+ GpuDriverHelper.initializeDriverParameters(applicationContext)
+ NativeLibrary.logDeviceInfo()
+
+ createNotificationChannels();
+ }
+
+ companion object {
+ var documentsTree: DocumentsTree? = null
+ lateinit var application: YuzuApplication
+
+ val appContext: Context
+ get() = application.applicationContext
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
new file mode 100644
index 000000000..94d5156cf
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
@@ -0,0 +1,333 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.activities
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.res.Configuration
+import android.graphics.Rect
+import android.hardware.Sensor
+import android.hardware.SensorEvent
+import android.hardware.SensorEventListener
+import android.hardware.SensorManager
+import android.hardware.display.DisplayManager
+import android.os.Bundle
+import android.view.Display
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.Surface
+import android.view.View
+import android.view.inputmethod.InputMethodManager
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.getSystemService
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.window.layout.WindowInfoTracker
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.fragments.EmulationFragment
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.ControllerMappingHelper
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import org.yuzu.yuzu_emu.utils.ForegroundService
+import org.yuzu.yuzu_emu.utils.InputHandler
+import org.yuzu.yuzu_emu.utils.NfcReader
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+import org.yuzu.yuzu_emu.utils.ThemeHelper
+import kotlin.math.roundToInt
+
+class EmulationActivity : AppCompatActivity(), SensorEventListener {
+ private var controllerMappingHelper: ControllerMappingHelper? = null
+
+ var isActivityRecreated = false
+ private var emulationFragment: EmulationFragment? = null
+ private lateinit var nfcReader: NfcReader
+ private lateinit var inputHandler: InputHandler
+
+ private val gyro = FloatArray(3)
+ private val accel = FloatArray(3)
+ private var motionTimestamp: Long = 0
+ private var flipMotionOrientation: Boolean = false
+
+ private lateinit var game: Game
+
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override fun onDestroy() {
+ stopForegroundService(this)
+ super.onDestroy()
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+
+ settingsViewModel.settings.loadSettings()
+
+ super.onCreate(savedInstanceState)
+ if (savedInstanceState == null) {
+ // Get params we were passed
+ game = intent.parcelable(EXTRA_SELECTED_GAME)!!
+ isActivityRecreated = false
+ } else {
+ isActivityRecreated = true
+ restoreState(savedInstanceState)
+ }
+ controllerMappingHelper = ControllerMappingHelper()
+
+ // Set these options now so that the SurfaceView the game renders into is the right size.
+ enableFullscreenImmersive()
+
+ setContentView(R.layout.activity_emulation)
+ window.decorView.setBackgroundColor(getColor(android.R.color.black))
+
+ // Find or create the EmulationFragment
+ emulationFragment =
+ supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment?
+ if (emulationFragment == null) {
+ emulationFragment = EmulationFragment.newInstance(game)
+ supportFragmentManager.beginTransaction()
+ .add(R.id.frame_emulation_fragment, emulationFragment!!)
+ .commit()
+ }
+ title = game.title
+
+ nfcReader = NfcReader(this)
+ nfcReader.initialize()
+
+ inputHandler = InputHandler()
+ inputHandler.initialize()
+
+ lifecycleScope.launch(Dispatchers.Main) {
+ lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+ WindowInfoTracker.getOrCreate(this@EmulationActivity)
+ .windowLayoutInfo(this@EmulationActivity)
+ .collect { emulationFragment?.updateCurrentLayout(this@EmulationActivity, it) }
+ }
+ }
+
+ // Start a foreground service to prevent the app from getting killed in the background
+ val startIntent = Intent(this, ForegroundService::class.java)
+ startForegroundService(startIntent)
+ }
+
+ override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ if (keyCode == KeyEvent.KEYCODE_ENTER) {
+ // Special case, we do not support multiline input, dismiss the keyboard.
+ val overlayView: View =
+ this.findViewById(R.id.surface_input_overlay)
+ val im =
+ overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
+ im.hideSoftInputFromWindow(overlayView.windowToken, 0)
+ } else {
+ val textChar = event.unicodeChar
+ if (textChar == 0) {
+ // No text, button input.
+ NativeLibrary.submitInlineKeyboardInput(keyCode)
+ } else {
+ // Text submitted.
+ NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
+ }
+ }
+ }
+ return super.onKeyDown(keyCode, event)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ nfcReader.startScanning()
+ startMotionSensorListener()
+
+ NativeLibrary.notifyOrientationChange(
+ EmulationMenuSettings.landscapeScreenLayout,
+ getAdjustedRotation()
+ )
+ }
+
+ override fun onPause() {
+ super.onPause()
+ nfcReader.stopScanning()
+ stopMotionSensorListener()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ nfcReader.onNewIntent(intent)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putParcelable(EXTRA_SELECTED_GAME, game)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchKeyEvent(event)
+ }
+
+ return inputHandler.dispatchKeyEvent(event)
+ }
+
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
+ event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
+ ) {
+ return super.dispatchGenericMotionEvent(event)
+ }
+
+ // Don't attempt to do anything if we are disconnecting a device.
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ return true
+ }
+
+ return inputHandler.dispatchGenericMotionEvent(event)
+ }
+
+ override fun onSensorChanged(event: SensorEvent) {
+ val rotation = this.display?.rotation
+ if (rotation == Surface.ROTATION_90) {
+ flipMotionOrientation = true
+ }
+ if (rotation == Surface.ROTATION_270) {
+ flipMotionOrientation = false
+ }
+
+ if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
+ if (flipMotionOrientation) {
+ accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
+ } else {
+ accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
+ accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
+ }
+ accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
+ }
+ if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
+ // Investigate why sensor value is off by 6x
+ if (flipMotionOrientation) {
+ gyro[0] = -event.values[1] / 6.0f
+ gyro[1] = event.values[0] / 6.0f
+ } else {
+ gyro[0] = event.values[1] / 6.0f
+ gyro[1] = -event.values[0] / 6.0f
+ }
+ gyro[2] = event.values[2] / 6.0f
+ }
+
+ // Only update state on accelerometer data
+ if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
+ return
+ }
+ val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
+ motionTimestamp = event.timestamp
+ NativeLibrary.onGamePadMotionEvent(
+ NativeLibrary.Player1Device,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ NativeLibrary.onGamePadMotionEvent(
+ NativeLibrary.ConsoleDevice,
+ deltaTimestamp,
+ gyro[0],
+ gyro[1],
+ gyro[2],
+ accel[0],
+ accel[1],
+ accel[2]
+ )
+ }
+
+ override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
+
+ private fun getAdjustedRotation():Int {
+ val rotation = getSystemService()!!.getDisplay(Display.DEFAULT_DISPLAY).rotation
+ val config: Configuration = resources.configuration
+
+ if ((config.screenLayout and Configuration.SCREENLAYOUT_LONG_YES) != 0 ||
+ (config.screenLayout and Configuration.SCREENLAYOUT_LONG_NO) == 0) {
+ return rotation
+ }
+ when (rotation) {
+ Surface.ROTATION_0 -> return Surface.ROTATION_90
+ Surface.ROTATION_90 -> return Surface.ROTATION_0
+ Surface.ROTATION_180 -> return Surface.ROTATION_270
+ Surface.ROTATION_270 -> return Surface.ROTATION_180
+ }
+ return rotation
+ }
+
+ private fun restoreState(savedInstanceState: Bundle) {
+ game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!!
+ }
+
+ private fun enableFullscreenImmersive() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ WindowInsetsControllerCompat(window, window.decorView).let { controller ->
+ controller.hide(WindowInsetsCompat.Type.systemBars())
+ controller.systemBarsBehavior =
+ WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ private fun startMotionSensorListener() {
+ val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+ sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME)
+ sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME)
+ }
+
+ private fun stopMotionSensorListener() {
+ val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
+ val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
+ val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
+
+ sensorManager.unregisterListener(this, gyroSensor)
+ sensorManager.unregisterListener(this, accelSensor)
+ }
+
+ companion object {
+ const val EXTRA_SELECTED_GAME = "SelectedGame"
+
+ fun launch(activity: AppCompatActivity, game: Game) {
+ val launcher = Intent(activity, EmulationActivity::class.java)
+ launcher.putExtra(EXTRA_SELECTED_GAME, game)
+ activity.startActivity(launcher)
+ }
+
+ fun stopForegroundService(activity: Activity) {
+ val startIntent = Intent(activity, ForegroundService::class.java)
+ startIntent.action = ForegroundService.ACTION_STOP
+ activity.startForegroundService(startIntent)
+ }
+
+ private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
+ if (view == null) {
+ return true
+ }
+ val viewBounds = Rect()
+ view.getGlobalVisibleRect(viewBounds)
+ return !viewBounds.contains(x.roundToInt(), y.roundToInt())
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
new file mode 100644
index 000000000..7f9e2e2d4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.net.Uri
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import coil.load
+import kotlinx.coroutines.launch
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.databinding.CardGameBinding
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
+import org.yuzu.yuzu_emu.model.GamesViewModel
+
+class GameAdapter(private val activity: AppCompatActivity) :
+ ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
+ // Create a new view.
+ val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.cardGame.setOnClickListener(this)
+
+ // Use that view to create a ViewHolder.
+ return GameViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ override fun getItemCount(): Int = currentList.size
+
+ /**
+ * Launches the game that was clicked on.
+ *
+ * @param view The card representing the game the user wants to play.
+ */
+ override fun onClick(view: View) {
+ val holder = view.tag as GameViewHolder
+
+ val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true
+ if (!gameExists) {
+ Toast.makeText(
+ YuzuApplication.appContext,
+ R.string.loader_error_file_not_found,
+ Toast.LENGTH_LONG
+ ).show()
+
+ ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
+ return
+ }
+
+ val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ preferences.edit()
+ .putLong(
+ holder.game.keyLastPlayedTime,
+ System.currentTimeMillis()
+ )
+ .apply()
+
+ EmulationActivity.launch(activity, holder.game)
+ }
+
+ inner class GameViewHolder(val binding: CardGameBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var game: Game
+
+ init {
+ binding.cardGame.tag = this
+ }
+
+ fun bind(game: Game) {
+ this.game = game
+
+ binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
+ activity.lifecycleScope.launch {
+ val bitmap = decodeGameIcon(game.path)
+ binding.imageGameScreen.load(bitmap) {
+ error(R.drawable.default_icon)
+ }
+ }
+
+ binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
+
+ binding.textGameTitle.postDelayed(
+ {
+ binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.textGameTitle.isSelected = true
+ },
+ 3000
+ )
+ }
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem.gameId == newItem.gameId
+ }
+
+ override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem == newItem
+ }
+ }
+
+ private fun decodeGameIcon(uri: String): Bitmap? {
+ val data = NativeLibrary.getIcon(uri)
+ return BitmapFactory.decodeByteArray(
+ data,
+ 0,
+ data.size,
+ BitmapFactory.Options()
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
new file mode 100644
index 000000000..b719dd539
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/HomeSettingAdapter.kt
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
+import org.yuzu.yuzu_emu.model.HomeSetting
+
+class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List) :
+ RecyclerView.Adapter(),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
+ val binding =
+ CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return HomeOptionViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int {
+ return options.size
+ }
+
+ override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
+ holder.bind(options[position])
+ }
+
+ override fun onClick(view: View) {
+ val holder = view.tag as HomeOptionViewHolder
+ holder.option.onClick.invoke()
+ }
+
+ inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var option: HomeSetting
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(option: HomeSetting) {
+ this.option = option
+ binding.optionTitle.text = activity.resources.getString(option.titleId)
+ binding.optionDescription.text = activity.resources.getString(option.descriptionId)
+ binding.optionIcon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ option.iconId,
+ activity.theme
+ )
+ )
+
+ when (option.titleId) {
+ R.string.get_early_access -> binding.optionLayout.background =
+ ContextCompat.getDrawable(
+ binding.optionCard.context,
+ R.drawable.premium_background
+ )
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
new file mode 100644
index 000000000..7006651d0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/LicenseAdapter.kt
@@ -0,0 +1,54 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment
+import org.yuzu.yuzu_emu.model.License
+
+class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List) :
+ RecyclerView.Adapter(),
+ View.OnClickListener {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
+ val binding =
+ ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.root.setOnClickListener(this)
+ return LicenseViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = licenses.size
+
+ override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) {
+ holder.bind(licenses[position])
+ }
+
+ override fun onClick(view: View) {
+ val license = (view.tag as LicenseViewHolder).license
+ LicenseBottomSheetDialogFragment.newInstance(license)
+ .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
+ }
+
+ inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) {
+ lateinit var license: License
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(license: License) {
+ this.license = license
+
+ val context = YuzuApplication.appContext
+ binding.textSettingName.text = context.getString(license.titleId)
+ binding.textSettingDescription.text = context.getString(license.descriptionId)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
new file mode 100644
index 000000000..481ddd5a5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.adapters
+
+import android.text.Html
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.res.ResourcesCompat
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.button.MaterialButton
+import org.yuzu.yuzu_emu.databinding.PageSetupBinding
+import org.yuzu.yuzu_emu.model.SetupPage
+
+class SetupAdapter(val activity: AppCompatActivity, val pages: List) :
+ RecyclerView.Adapter() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
+ val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return SetupPageViewHolder(binding)
+ }
+
+ override fun getItemCount(): Int = pages.size
+
+ override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
+ holder.bind(pages[position])
+
+ inner class SetupPageViewHolder(val binding: PageSetupBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var page: SetupPage
+
+ init {
+ itemView.tag = this
+ }
+
+ fun bind(page: SetupPage) {
+ this.page = page
+ binding.icon.setImageDrawable(
+ ResourcesCompat.getDrawable(
+ activity.resources,
+ page.iconId,
+ activity.theme
+ )
+ )
+ binding.textTitle.text = activity.resources.getString(page.titleId)
+ binding.textDescription.text =
+ Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
+
+ binding.buttonAction.apply {
+ text = activity.resources.getString(page.buttonTextId)
+ if (page.buttonIconId != 0) {
+ icon = ResourcesCompat.getDrawable(
+ activity.resources,
+ page.buttonIconId,
+ activity.theme
+ )
+ }
+ iconGravity =
+ if (page.leftAlignedIcon) {
+ MaterialButton.ICON_GRAVITY_START
+ } else {
+ MaterialButton.ICON_GRAVITY_END
+ }
+ setOnClickListener {
+ page.buttonAction.invoke()
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt
new file mode 100644
index 000000000..82a6712b6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/SoftwareKeyboard.kt
@@ -0,0 +1,121 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.applets.keyboard
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.view.KeyEvent
+import android.view.View
+import android.view.WindowInsets
+import android.view.inputmethod.InputMethodManager
+import androidx.annotation.Keep
+import androidx.core.view.ViewCompat
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment
+import java.io.Serializable
+
+@Keep
+object SoftwareKeyboard {
+ lateinit var data: KeyboardData
+ val dataLock = Object()
+
+ private fun executeNormalImpl(config: KeyboardConfig) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+ data = KeyboardData(SwkbdResult.Cancel.ordinal, "")
+ val fragment = KeyboardDialogFragment.newInstance(config)
+ fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG)
+ }
+
+ private fun executeInlineImpl(config: KeyboardConfig) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+
+ val overlayView = emulationActivity!!.findViewById(R.id.surface_input_overlay)
+ val im =
+ overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
+ im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED)
+
+ // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result.
+ val handler = Handler(Looper.myLooper()!!)
+ val delayMs = 500
+ handler.postDelayed(object : Runnable {
+ override fun run() {
+ val insets = ViewCompat.getRootWindowInsets(overlayView)
+ val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime())
+ if (isKeyboardVisible) {
+ handler.postDelayed(this, delayMs.toLong())
+ return
+ }
+
+ // No longer visible, submit the result.
+ NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER)
+ }
+ }, delayMs.toLong())
+ }
+
+ @JvmStatic
+ fun executeNormal(config: KeyboardConfig): KeyboardData {
+ NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) }
+ synchronized(dataLock) {
+ dataLock.wait()
+ }
+ return data
+ }
+
+ @JvmStatic
+ fun executeInline(config: KeyboardConfig) {
+ NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) }
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdType
+ enum class SwkbdType {
+ Normal,
+ NumberPad,
+ Qwerty,
+ Unknown3,
+ Latin,
+ SimplifiedChinese,
+ TraditionalChinese,
+ Korean
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdPasswordMode
+ enum class SwkbdPasswordMode {
+ Disabled,
+ Enabled
+ }
+
+ // Corresponds to Service::AM::Applets::SwkbdResult
+ enum class SwkbdResult {
+ Ok,
+ Cancel
+ }
+
+ @Keep
+ data class KeyboardConfig(
+ var ok_text: String? = null,
+ var header_text: String? = null,
+ var sub_text: String? = null,
+ var guide_text: String? = null,
+ var initial_text: String? = null,
+ var left_optional_symbol_key: Short = 0,
+ var right_optional_symbol_key: Short = 0,
+ var max_text_length: Int = 0,
+ var min_text_length: Int = 0,
+ var initial_cursor_position: Int = 0,
+ var type: Int = 0,
+ var password_mode: Int = 0,
+ var text_draw_type: Int = 0,
+ var key_disable_flags: Int = 0,
+ var use_blur_background: Boolean = false,
+ var enable_backspace_button: Boolean = false,
+ var enable_return_button: Boolean = false,
+ var disable_cancel_button: Boolean = false
+ ) : Serializable
+
+ // Corresponds to Frontend::KeyboardData
+ @Keep
+ data class KeyboardData(var result: Int, var text: String)
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt
new file mode 100644
index 000000000..607a3d506
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/applets/keyboard/ui/KeyboardDialogFragment.kt
@@ -0,0 +1,100 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.applets.keyboard.ui
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.InputFilter
+import android.text.InputType
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard
+import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig
+import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding
+import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
+
+class KeyboardDialogFragment : DialogFragment() {
+ private lateinit var binding: DialogEditTextBinding
+ private lateinit var config: KeyboardConfig
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ binding = DialogEditTextBinding.inflate(layoutInflater)
+ config = requireArguments().serializable(CONFIG)!!
+
+ // Set up the input
+ binding.editText.hint = config.initial_text
+ binding.editText.isSingleLine = !config.enable_return_button
+ binding.editText.filters =
+ arrayOf(InputFilter.LengthFilter(config.max_text_length))
+
+ // Handle input type
+ var inputType: Int
+ when (config.type) {
+ SoftwareKeyboard.SwkbdType.Normal.ordinal,
+ SoftwareKeyboard.SwkbdType.Qwerty.ordinal,
+ SoftwareKeyboard.SwkbdType.Unknown3.ordinal,
+ SoftwareKeyboard.SwkbdType.Latin.ordinal,
+ SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal,
+ SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal,
+ SoftwareKeyboard.SwkbdType.Korean.ordinal -> {
+ inputType = InputType.TYPE_CLASS_TEXT
+ if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
+ inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ }
+ SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> {
+ inputType = InputType.TYPE_CLASS_NUMBER
+ if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
+ inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ }
+ }
+ else -> {
+ inputType = InputType.TYPE_CLASS_TEXT
+ if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
+ inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ }
+ }
+ }
+ binding.editText.inputType = inputType
+
+ val headerText =
+ config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) }
+ val okText =
+ config.ok_text!!.ifEmpty { resources.getString(R.string.submit) }
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(headerText)
+ .setView(binding.root)
+ .setPositiveButton(okText) { _, _ ->
+ SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal
+ SoftwareKeyboard.data.text = binding.editText.text.toString()
+ }
+ .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ ->
+ SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal
+ }
+ .create()
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ super.onDismiss(dialog)
+ synchronized(SoftwareKeyboard.dataLock) {
+ SoftwareKeyboard.dataLock.notifyAll()
+ }
+ }
+
+ companion object {
+ const val TAG = "KeyboardDialogFragment"
+ const val CONFIG = "keyboard_config"
+
+ fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment {
+ val frag = KeyboardDialogFragment()
+ val args = Bundle()
+ args.putSerializable(CONFIG, config)
+ frag.arguments = args
+ return frag
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt
new file mode 100644
index 000000000..3b1559c80
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/DiskShaderCacheProgress.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache
+
+import androidx.annotation.Keep
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment
+
+@Keep
+object DiskShaderCacheProgress {
+ val finishLock = Object()
+ private lateinit var fragment: ShaderProgressDialogFragment
+
+ private fun prepareDialog() {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()!!
+ emulationActivity.runOnUiThread {
+ fragment = ShaderProgressDialogFragment.newInstance(
+ emulationActivity.getString(R.string.loading),
+ emulationActivity.getString(R.string.preparing_shaders)
+ )
+ fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG)
+ }
+ synchronized(finishLock) { finishLock.wait() }
+ }
+
+ @JvmStatic
+ fun loadProgress(stage: Int, progress: Int, max: Int) {
+ val emulationActivity = NativeLibrary.sEmulationActivity.get()
+ ?: error("[DiskShaderCacheProgress] EmulationActivity not present")
+
+ when (LoadCallbackStage.values()[stage]) {
+ LoadCallbackStage.Prepare -> prepareDialog()
+ LoadCallbackStage.Build -> fragment.onUpdateProgress(
+ emulationActivity.getString(R.string.building_shaders),
+ progress,
+ max
+ )
+ LoadCallbackStage.Complete -> fragment.dismiss()
+ }
+ }
+
+ // Equivalent to VideoCore::LoadCallbackStage
+ enum class LoadCallbackStage {
+ Prepare, Build, Complete
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt
new file mode 100644
index 000000000..bf6f0366d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ShaderProgressViewModel.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class ShaderProgressViewModel : ViewModel() {
+ private val _progress = MutableLiveData(0)
+ val progress: LiveData get() = _progress
+
+ private val _max = MutableLiveData(0)
+ val max: LiveData get() = _max
+
+ private val _message = MutableLiveData("")
+ val message: LiveData get() = _message
+
+ fun setProgress(progress: Int) {
+ _progress.postValue(progress)
+ }
+
+ fun setMax(max: Int) {
+ _max.postValue(max)
+ }
+
+ fun setMessage(msg: String) {
+ _message.postValue(msg)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt
new file mode 100644
index 000000000..2c68c9ac3
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/disk_shader_cache/ui/ShaderProgressDialogFragment.kt
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.disk_shader_cache.ui
+
+import android.app.Dialog
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress
+import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel
+
+class ShaderProgressDialogFragment : DialogFragment() {
+ private var _binding: DialogProgressBarBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var alertDialog: AlertDialog
+
+ private lateinit var shaderProgressViewModel: ShaderProgressViewModel
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ _binding = DialogProgressBarBinding.inflate(layoutInflater)
+ shaderProgressViewModel =
+ ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java]
+
+ val title = requireArguments().getString(TITLE)
+ val message = requireArguments().getString(MESSAGE)
+
+ isCancelable = false
+ alertDialog = MaterialAlertDialogBuilder(requireActivity())
+ .setView(binding.root)
+ .setTitle(title)
+ .setMessage(message)
+ .create()
+ return alertDialog
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress ->
+ binding.progressBar.progress = progress
+ setUpdateText()
+ }
+ shaderProgressViewModel.max.observe(viewLifecycleOwner) { max ->
+ binding.progressBar.max = max
+ setUpdateText()
+ }
+ shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg ->
+ alertDialog.setMessage(msg)
+ }
+ synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ fun onUpdateProgress(msg: String, progress: Int, max: Int) {
+ shaderProgressViewModel.setProgress(progress)
+ shaderProgressViewModel.setMax(max)
+ shaderProgressViewModel.setMessage(msg)
+ }
+
+ private fun setUpdateText() {
+ binding.progressText.text = String.format(
+ "%d/%d",
+ shaderProgressViewModel.progress.value,
+ shaderProgressViewModel.max.value
+ )
+ }
+
+ companion object {
+ const val TAG = "ProgressDialogFragment"
+ const val TITLE = "title"
+ const val MESSAGE = "message"
+
+ fun newInstance(title: String, message: String): ShaderProgressDialogFragment {
+ val frag = ShaderProgressDialogFragment()
+ val args = Bundle()
+ args.putString(TITLE, title)
+ args.putString(MESSAGE, message)
+ frag.arguments = args
+ return frag
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
new file mode 100644
index 000000000..4c3a9ca80
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
@@ -0,0 +1,302 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// SPDX-License-Identifier: MPL-2.0
+// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
+
+package org.yuzu.yuzu_emu.features
+
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.os.CancellationSignal
+import android.os.ParcelFileDescriptor
+import android.provider.DocumentsContract
+import android.provider.DocumentsProvider
+import android.webkit.MimeTypeMap
+import org.yuzu.yuzu_emu.BuildConfig
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.getPublicFilesDir
+import java.io.*
+
+class DocumentProvider : DocumentsProvider() {
+ private val baseDirectory: File
+ get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath)
+
+ companion object {
+ private val DEFAULT_ROOT_PROJECTION: Array = arrayOf(
+ DocumentsContract.Root.COLUMN_ROOT_ID,
+ DocumentsContract.Root.COLUMN_MIME_TYPES,
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.COLUMN_ICON,
+ DocumentsContract.Root.COLUMN_TITLE,
+ DocumentsContract.Root.COLUMN_SUMMARY,
+ DocumentsContract.Root.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
+ )
+
+ private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_MIME_TYPE,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+ DocumentsContract.Document.COLUMN_FLAGS,
+ DocumentsContract.Document.COLUMN_SIZE
+ )
+
+ const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user"
+ const val ROOT_ID: String = "root"
+ }
+
+ override fun onCreate(): Boolean {
+ return true
+ }
+
+ /**
+ * @return The [File] that corresponds to the document ID supplied by [getDocumentId]
+ */
+ private fun getFile(documentId: String): File {
+ if (documentId.startsWith(ROOT_ID)) {
+ val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1))
+ if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found")
+ return file
+ } else {
+ throw FileNotFoundException("'$documentId' is not in any known root")
+ }
+ }
+
+ /**
+ * @return A unique ID for the provided [File]
+ */
+ private fun getDocumentId(file: File): String {
+ return "$ROOT_ID/${file.toRelativeString(baseDirectory)}"
+ }
+
+ override fun queryRoots(projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
+
+ cursor.newRow().apply {
+ add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
+ add(DocumentsContract.Root.COLUMN_SUMMARY, null)
+ add(
+ DocumentsContract.Root.COLUMN_FLAGS,
+ DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
+ )
+ add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
+ add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory))
+ add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*")
+ add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace)
+ add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
+ }
+
+ return cursor
+ }
+
+ override fun queryDocument(documentId: String?, projection: Array?): Cursor {
+ val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+ return includeFile(cursor, documentId, null)
+ }
+
+ override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean {
+ return documentId?.startsWith(parentDocumentId!!) ?: false
+ }
+
+ /**
+ * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file
+ */
+ private fun File.resolveWithoutConflict(name: String): File {
+ var file = resolve(name)
+ if (file.exists()) {
+ var noConflictId =
+ 1 // Makes sure two files don't have the same name by adding a number to the end
+ val extension = name.substringAfterLast('.')
+ val baseName = name.substringBeforeLast('.')
+ while (file.exists())
+ file = resolve("$baseName (${noConflictId++}).$extension")
+ }
+ return file
+ }
+
+ override fun createDocument(
+ parentDocumentId: String?,
+ mimeType: String?,
+ displayName: String
+ ): String {
+ val parentFile = getFile(parentDocumentId!!)
+ val newFile = parentFile.resolveWithoutConflict(displayName)
+
+ try {
+ if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) {
+ if (!newFile.mkdir())
+ throw IOException("Failed to create directory")
+ } else {
+ if (!newFile.createNewFile())
+ throw IOException("Failed to create file")
+ }
+ } catch (e: IOException) {
+ throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}")
+ }
+
+ return getDocumentId(newFile)
+ }
+
+ override fun deleteDocument(documentId: String?) {
+ val file = getFile(documentId!!)
+ if (!file.delete())
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ }
+
+ override fun removeDocument(documentId: String, parentDocumentId: String?) {
+ val parent = getFile(parentDocumentId!!)
+ val file = getFile(documentId)
+
+ if (parent == file || file.parentFile == null || file.parentFile!! == parent) {
+ if (!file.delete())
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ } else {
+ throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
+ }
+ }
+
+ override fun renameDocument(documentId: String?, displayName: String?): String {
+ if (displayName == null)
+ throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null")
+
+ val sourceFile = getFile(documentId!!)
+ val sourceParentFile = sourceFile.parentFile
+ ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent")
+ val destFile = sourceParentFile.resolve(displayName)
+
+ try {
+ if (!sourceFile.renameTo(destFile))
+ throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'")
+ } catch (e: Exception) {
+ throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}")
+ }
+
+ return getDocumentId(destFile)
+ }
+
+ private fun copyDocument(
+ sourceDocumentId: String, sourceParentDocumentId: String,
+ targetParentDocumentId: String?
+ ): String {
+ if (!isChildDocument(sourceParentDocumentId, sourceDocumentId))
+ throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'")
+
+ return copyDocument(sourceDocumentId, targetParentDocumentId)
+ }
+
+ override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String {
+ val parent = getFile(targetParentDocumentId!!)
+ val oldFile = getFile(sourceDocumentId)
+ val newFile = parent.resolveWithoutConflict(oldFile.name)
+
+ try {
+ if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true)))
+ throw IOException("Couldn't create new file")
+
+ FileInputStream(oldFile).use { inStream ->
+ FileOutputStream(newFile).use { outStream ->
+ inStream.copyTo(outStream)
+ }
+ }
+ } catch (e: IOException) {
+ throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}")
+ }
+
+ return getDocumentId(newFile)
+ }
+
+ override fun moveDocument(
+ sourceDocumentId: String, sourceParentDocumentId: String?,
+ targetParentDocumentId: String?
+ ): String {
+ try {
+ val newDocumentId = copyDocument(
+ sourceDocumentId, sourceParentDocumentId!!,
+ targetParentDocumentId
+ )
+ removeDocument(sourceDocumentId, sourceParentDocumentId)
+ return newDocumentId
+ } catch (e: FileNotFoundException) {
+ throw FileNotFoundException("Couldn't move document '$sourceDocumentId'")
+ }
+ }
+
+ private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor {
+ val localDocumentId = documentId ?: file?.let { getDocumentId(it) }
+ val localFile = file ?: getFile(documentId!!)
+
+ var flags = 0
+ if (localFile.isDirectory && localFile.canWrite()) {
+ flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
+ } else if (localFile.canWrite()) {
+ flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
+
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY
+ flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
+ }
+
+ cursor.newRow().apply {
+ add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
+ add(
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name
+ )
+ add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
+ add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
+ add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
+ add(DocumentsContract.Document.COLUMN_FLAGS, flags)
+ if (localFile == baseDirectory)
+ add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
+ }
+
+ return cursor
+ }
+
+ private fun getTypeForFile(file: File): Any {
+ return if (file.isDirectory)
+ DocumentsContract.Document.MIME_TYPE_DIR
+ else
+ getTypeForName(file.name)
+ }
+
+ private fun getTypeForName(name: String): Any {
+ val lastDot = name.lastIndexOf('.')
+ if (lastDot >= 0) {
+ val extension = name.substring(lastDot + 1)
+ val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+ if (mime != null)
+ return mime
+ }
+ return "application/octect-stream"
+ }
+
+ override fun queryChildDocuments(
+ parentDocumentId: String?,
+ projection: Array?,
+ sortOrder: String?
+ ): Cursor {
+ var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
+
+ val parent = getFile(parentDocumentId!!)
+ for (file in parent.listFiles()!!)
+ cursor = includeFile(cursor, null, file)
+
+ return cursor
+ }
+
+ override fun openDocument(
+ documentId: String?,
+ mode: String?,
+ signal: CancellationSignal?
+ ): ParcelFileDescriptor {
+ val file = documentId?.let { getFile(it) }
+ val accessMode = ParcelFileDescriptor.parseMode(mode)
+ return ParcelFileDescriptor.open(file, accessMode)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt
new file mode 100644
index 000000000..a6e9833ee
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractBooleanSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractBooleanSetting : AbstractSetting {
+ var boolean: Boolean
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt
new file mode 100644
index 000000000..6fe4bc263
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractFloatSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractFloatSetting : AbstractSetting {
+ var float: Float
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt
new file mode 100644
index 000000000..892b7dcfe
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractIntSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractIntSetting : AbstractSetting {
+ var int: Int
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt
new file mode 100644
index 000000000..258580209
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractSetting.kt
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractSetting {
+ val key: String?
+ val section: String?
+ val isRuntimeEditable: Boolean
+ val valueAsString: String
+ val defaultValue: Any
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt
new file mode 100644
index 000000000..0d02c5997
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/AbstractStringSetting.kt
@@ -0,0 +1,8 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+interface AbstractStringSetting : AbstractSetting {
+ var string: String
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
new file mode 100644
index 000000000..3dfd66779
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/BooleanSetting.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class BooleanSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Boolean
+) : AbstractBooleanSetting {
+ USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false);
+
+ override var boolean: Boolean = defaultValue
+
+ override val valueAsString: String
+ get() = boolean.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ USE_CUSTOM_RTC
+ )
+
+ fun from(key: String): BooleanSetting? =
+ BooleanSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt
new file mode 100644
index 000000000..e5545a916
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/FloatSetting.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class FloatSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Float
+) : AbstractFloatSetting {
+ // No float settings currently exist
+ EMPTY_SETTING("", "", 0f);
+
+ override var float: Float = defaultValue
+
+ override val valueAsString: String
+ get() = float.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = emptyList()
+
+ fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
new file mode 100644
index 000000000..c5722a5a1
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/IntSetting.kt
@@ -0,0 +1,131 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class IntSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: Int
+) : AbstractIntSetting {
+ RENDERER_USE_SPEED_LIMIT(
+ "use_speed_limit",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ USE_DOCKED_MODE(
+ "use_docked_mode",
+ Settings.SECTION_SYSTEM,
+ 0
+ ),
+ RENDERER_USE_DISK_SHADER_CACHE(
+ "use_disk_shader_cache",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_FORCE_MAX_CLOCK(
+ "force_max_clock",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ASYNCHRONOUS_SHADERS(
+ "use_asynchronous_shaders",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_DEBUG(
+ "debug",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_SPEED_LIMIT(
+ "speed_limit",
+ Settings.SECTION_RENDERER,
+ 100
+ ),
+ CPU_ACCURACY(
+ "cpu_accuracy",
+ Settings.SECTION_CPU,
+ 0
+ ),
+ REGION_INDEX(
+ "region_index",
+ Settings.SECTION_SYSTEM,
+ -1
+ ),
+ LANGUAGE_INDEX(
+ "language_index",
+ Settings.SECTION_SYSTEM,
+ 1
+ ),
+ RENDERER_BACKEND(
+ "backend",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ACCURACY(
+ "gpu_accuracy",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_RESOLUTION(
+ "resolution_setup",
+ Settings.SECTION_RENDERER,
+ 2
+ ),
+ RENDERER_VSYNC(
+ "use_vsync",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_SCALING_FILTER(
+ "scaling_filter",
+ Settings.SECTION_RENDERER,
+ 1
+ ),
+ RENDERER_ANTI_ALIASING(
+ "anti_aliasing",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ RENDERER_ASPECT_RATIO(
+ "aspect_ratio",
+ Settings.SECTION_RENDERER,
+ 0
+ ),
+ AUDIO_VOLUME(
+ "volume",
+ Settings.SECTION_AUDIO,
+ 100
+ );
+
+ override var int: Int = defaultValue
+
+ override val valueAsString: String
+ get() = int.toString()
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ RENDERER_USE_DISK_SHADER_CACHE,
+ RENDERER_ASYNCHRONOUS_SHADERS,
+ RENDERER_DEBUG,
+ RENDERER_BACKEND,
+ RENDERER_RESOLUTION,
+ RENDERER_VSYNC
+ )
+
+ fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = IntSetting.values().forEach { it.int = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt
new file mode 100644
index 000000000..474f598a9
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingSection.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+/**
+ * A semantically-related group of Settings objects. These Settings are
+ * internally stored as a HashMap.
+ */
+class SettingSection(val name: String) {
+ val settings = HashMap()
+
+ /**
+ * Convenience method; inserts a value directly into the backing HashMap.
+ *
+ * @param setting The Setting to be inserted.
+ */
+ fun putSetting(setting: AbstractSetting) {
+ settings[setting.key!!] = setting
+ }
+
+ /**
+ * Convenience method; gets a value directly from the backing HashMap.
+ *
+ * @param key Used to retrieve the Setting.
+ * @return A Setting object (you should probably cast this before using)
+ */
+ fun getSetting(key: String): AbstractSetting? {
+ return settings[key]
+ }
+
+ fun mergeSection(settingSection: SettingSection) {
+ for (setting in settingSection.settings.values) {
+ putSetting(setting)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
new file mode 100644
index 000000000..8df20b928
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/Settings.kt
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+import android.text.TextUtils
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import java.util.*
+
+class Settings {
+ private var gameId: String? = null
+
+ var isLoaded = false
+
+ /**
+ * A HashMap, SettingSection> that constructs a new SettingSection instead of returning null
+ * when getting a key not already in the map
+ */
+ class SettingsSectionMap : HashMap() {
+ override operator fun get(key: String): SettingSection? {
+ if (!super.containsKey(key)) {
+ val section = SettingSection(key)
+ super.put(key, section)
+ return section
+ }
+ return super.get(key)
+ }
+ }
+
+ var sections: HashMap = SettingsSectionMap()
+
+ fun getSection(sectionName: String): SettingSection? {
+ return sections[sectionName]
+ }
+
+ val isEmpty: Boolean
+ get() = sections.isEmpty()
+
+ fun loadSettings(view: SettingsActivityView? = 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) {
+ for ((key, updatedSection) in updatedSections) {
+ if (sections.containsKey(key)) {
+ val originalSection = sections[key]
+ originalSection!!.mergeSection(updatedSection!!)
+ } else {
+ sections[key] = updatedSection
+ }
+ }
+ }
+
+ fun loadSettings(gameId: String, view: SettingsActivityView) {
+ this.gameId = gameId
+ loadSettings(view)
+ }
+
+ fun saveSettings(view: SettingsActivityView) {
+ if (TextUtils.isEmpty(gameId)) {
+ view.showToastMessage(
+ YuzuApplication.appContext.getString(R.string.ini_saved),
+ false
+ )
+
+ for ((fileName, sectionNames) in configFileSectionsMap) {
+ val iniSections = TreeMap()
+ for (section in sectionNames) {
+ iniSections[section] = sections[section]!!
+ }
+
+ SettingsFile.saveFile(fileName, iniSections, view)
+ }
+ } else {
+ // Custom game settings
+ view.showToastMessage(
+ YuzuApplication.appContext.getString(R.string.gameid_saved, gameId),
+ false
+ )
+
+ SettingsFile.saveCustomGameSettings(gameId, sections)
+ }
+ }
+
+ companion object {
+ const val SECTION_GENERAL = "General"
+ const val SECTION_SYSTEM = "System"
+ const val SECTION_RENDERER = "Renderer"
+ const val SECTION_AUDIO = "Audio"
+ const val SECTION_CPU = "Cpu"
+ const val SECTION_THEME = "Theme"
+ const val 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> = HashMap()
+
+ init {
+ configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] =
+ listOf(
+ SECTION_GENERAL,
+ SECTION_SYSTEM,
+ SECTION_RENDERER,
+ SECTION_AUDIO,
+ SECTION_CPU
+ )
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt
new file mode 100644
index 000000000..bd9233d62
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/SettingsViewModel.kt
@@ -0,0 +1,10 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+import androidx.lifecycle.ViewModel
+
+class SettingsViewModel : ViewModel() {
+ val settings = Settings()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt
new file mode 100644
index 000000000..63f95690c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/StringSetting.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model
+
+enum class StringSetting(
+ override val key: String,
+ override val section: String,
+ override val defaultValue: String
+) : AbstractStringSetting {
+ CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0");
+
+ override var string: String = defaultValue
+
+ override val valueAsString: String
+ get() = string
+
+ override val isRuntimeEditable: Boolean
+ get() {
+ for (setting in NOT_RUNTIME_EDITABLE) {
+ if (setting == this) {
+ return false
+ }
+ }
+ return true
+ }
+
+ companion object {
+ private val NOT_RUNTIME_EDITABLE = listOf(
+ CUSTOM_RTC
+ )
+
+ fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key }
+
+ fun clear() = StringSetting.values().forEach { it.string = it.defaultValue }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt
new file mode 100644
index 000000000..bc0bf7788
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/DateTimeSetting.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+
+class DateTimeSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val key: String? = null,
+ private val defaultValue: String? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_DATETIME_SETTING
+
+ val value: String
+ get() = if (setting != null) {
+ val setting = setting as AbstractStringSetting
+ setting.string
+ } else {
+ defaultValue!!
+ }
+
+ fun setSelectedValue(datetime: String): AbstractStringSetting {
+ val stringSetting = setting as AbstractStringSetting
+ stringSetting.string = datetime
+ return stringSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt
new file mode 100644
index 000000000..0f8edbfb0
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/HeaderSetting.kt
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class HeaderSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_HEADER
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt
new file mode 100644
index 000000000..caaab50d8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/RunnableSetting.kt
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+class RunnableSetting(
+ titleId: Int,
+ descriptionId: Int,
+ val isRuntimeRunnable: Boolean,
+ val runnable: () -> Unit
+) : SettingsItem(null, titleId, descriptionId) {
+ override val type = TYPE_RUNNABLE
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
new file mode 100644
index 000000000..07520849e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SettingsItem.kt
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+/**
+ * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
+ * Each one corresponds to a [AbstractSetting] object, so this class's subclasses
+ * should vaguely correspond to those subclasses. There are a few with multiple analogues
+ * and a few with none (Headers, for example, do not correspond to anything in the ini
+ * file.)
+ */
+abstract class SettingsItem(
+ var setting: AbstractSetting?,
+ val nameId: Int,
+ val descriptionId: Int
+) {
+ abstract val type: Int
+
+ val isEditable: Boolean
+ get() {
+ if (!NativeLibrary.isRunning()) return true
+ return setting?.isRuntimeEditable ?: false
+ }
+
+ companion object {
+ const val TYPE_HEADER = 0
+ const val TYPE_SWITCH = 1
+ const val TYPE_SINGLE_CHOICE = 2
+ const val TYPE_SLIDER = 3
+ const val TYPE_SUBMENU = 4
+ const val TYPE_STRING_SINGLE_CHOICE = 5
+ const val TYPE_DATETIME_SETTING = 6
+ const val TYPE_RUNNABLE = 7
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt
new file mode 100644
index 000000000..9eac9904e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SingleChoiceSetting.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+
+class SingleChoiceSetting(
+ setting: AbstractIntSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val choicesId: Int,
+ val valuesId: Int,
+ val key: String? = null,
+ val defaultValue: Int? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SINGLE_CHOICE
+
+ val selectedValue: Int
+ get() = if (setting != null) {
+ val setting = setting as AbstractIntSetting
+ setting.int
+ } else {
+ defaultValue!!
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Int): AbstractIntSetting {
+ val intSetting = setting as AbstractIntSetting
+ intSetting.int = selection
+ return intSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt
new file mode 100644
index 000000000..842648ce4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SliderSetting.kt
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.utils.Log
+import kotlin.math.roundToInt
+
+class SliderSetting(
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val min: Int,
+ val max: Int,
+ val units: String,
+ val key: String? = null,
+ val defaultValue: Int? = null,
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SLIDER
+
+ val selectedValue: Int
+ get() {
+ val setting = setting ?: return defaultValue!!
+ return when (setting) {
+ is AbstractIntSetting -> setting.int
+ is AbstractFloatSetting -> setting.float.roundToInt()
+ else -> {
+ Log.error("[SliderSetting] Error casting setting type.")
+ -1
+ }
+ }
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Int): AbstractIntSetting {
+ val intSetting = setting as AbstractIntSetting
+ intSetting.int = selection
+ return intSetting
+ }
+
+ /**
+ * Write a value to the backing float. If that float was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the float.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: Float): AbstractFloatSetting {
+ val floatSetting = setting as AbstractFloatSetting
+ floatSetting.float = selection
+ return floatSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt
new file mode 100644
index 000000000..9e9b00d10
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/StringSingleChoiceSetting.kt
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+
+class StringSingleChoiceSetting(
+ val key: String? = null,
+ setting: AbstractSetting?,
+ titleId: Int,
+ descriptionId: Int,
+ val choicesId: Array,
+ private val valuesId: Array?,
+ private val defaultValue: String? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_STRING_SINGLE_CHOICE
+
+ fun getValueAt(index: Int): String? {
+ if (valuesId == null) return null
+ return if (index >= 0 && index < valuesId.size) {
+ valuesId[index]
+ } else ""
+ }
+
+ val selectedValue: String
+ get() = if (setting != null) {
+ val setting = setting as AbstractStringSetting
+ setting.string
+ } else {
+ defaultValue!!
+ }
+ val selectValueIndex: Int
+ get() {
+ val selectedValue = selectedValue
+ for (i in valuesId!!.indices) {
+ if (valuesId[i] == selectedValue) {
+ return i
+ }
+ }
+ return -1
+ }
+
+ /**
+ * Write a value to the backing int. If that int was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param selection New value of the int.
+ * @return the existing setting with the new value applied.
+ */
+ fun setSelectedValue(selection: String): AbstractStringSetting {
+ val stringSetting = setting as AbstractStringSetting
+ stringSetting.string = selection
+ return stringSetting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt
new file mode 100644
index 000000000..a3ef59c2f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SubmenuSetting.kt
@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class SubmenuSetting(
+ titleId: Int,
+ descriptionId: Int,
+ val menuKey: String
+) : SettingsItem(null, titleId, descriptionId) {
+ override val type = TYPE_SUBMENU
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt
new file mode 100644
index 000000000..90b198718
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/model/view/SwitchSetting.kt
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.model.view
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+
+class SwitchSetting(
+ setting: AbstractSetting,
+ titleId: Int,
+ descriptionId: Int,
+ val key: String? = null,
+ val defaultValue: Any? = null
+) : SettingsItem(setting, titleId, descriptionId) {
+ override val type = TYPE_SWITCH
+
+ val isChecked: Boolean
+ get() {
+ if (setting == null) {
+ return defaultValue as Boolean
+ }
+
+ // Try integer setting
+ try {
+ val setting = setting as AbstractIntSetting
+ return setting.int == 1
+ } catch (_: ClassCastException) {
+ }
+
+ // Try boolean setting
+ try {
+ val setting = setting as AbstractBooleanSetting
+ return setting.boolean
+ } catch (_: ClassCastException) {
+ }
+ return defaultValue as Boolean
+ }
+
+ /**
+ * Write a value to the backing boolean. If that boolean was previously null,
+ * initializes a new one and returns it, so it can be added to the Hashmap.
+ *
+ * @param checked Pretty self explanatory.
+ * @return the existing setting with the new value applied.
+ */
+ fun setChecked(checked: Boolean): AbstractSetting {
+ // Try integer setting
+ try {
+ val setting = setting as AbstractIntSetting
+ setting.int = if (checked) 1 else 0
+ return setting
+ } catch (_: ClassCastException) {
+ }
+
+ // Try boolean setting
+ val setting = setting as AbstractBooleanSetting
+ setting.boolean = checked
+ return setting
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
new file mode 100644
index 000000000..72e2cce2a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivity.kt
@@ -0,0 +1,243 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.Menu
+import android.view.View
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.activity.OnBackPressedCallback
+import androidx.core.view.updatePadding
+import com.google.android.material.color.MaterialColors
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
+import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.utils.*
+import java.io.IOException
+
+class SettingsActivity : AppCompatActivity(), SettingsActivityView {
+ private val presenter = SettingsActivityPresenter(this)
+
+ private lateinit var binding: ActivitySettingsBinding
+
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override val settings: Settings get() = settingsViewModel.settings
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ binding = ActivitySettingsBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+
+ val launcher = intent
+ val gameID = launcher.getStringExtra(ARG_GAME_ID)
+ val menuTag = launcher.getStringExtra(ARG_MENU_TAG)
+ presenter.onCreate(savedInstanceState, menuTag!!, gameID!!)
+
+ // Show "Back" button in the action bar for navigation
+ setSupportActionBar(binding.toolbarSettings)
+ supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+
+ if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
+ binding.navigationBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.navigationBarShade,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ }
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() = navigateBack()
+ })
+
+ setInsets()
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ navigateBack()
+ return true
+ }
+
+ private fun navigateBack() {
+ if (supportFragmentManager.backStackEntryCount > 0) {
+ supportFragmentManager.popBackStack()
+ } else {
+ finish()
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ val inflater = menuInflater
+ inflater.inflate(R.menu.menu_settings, menu)
+ return true
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ // Critical: If super method is not called, rotations will be busted.
+ super.onSaveInstanceState(outState)
+ presenter.saveState(outState)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ presenter.onStart()
+ }
+
+ /**
+ * If this is called, the user has left the settings screen (potentially through the
+ * home button) and will expect their changes to be persisted. So we kick off an
+ * IntentService which will do so on a background thread.
+ */
+ override fun onStop() {
+ super.onStop()
+ presenter.onStop(isFinishing)
+ }
+
+ override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) {
+ if (!addToStack && settingsFragment != null) {
+ return
+ }
+
+ val transaction = supportFragmentManager.beginTransaction()
+ if (addToStack) {
+ if (areSystemAnimationsEnabled()) {
+ transaction.setCustomAnimations(
+ R.anim.anim_settings_fragment_in,
+ R.anim.anim_settings_fragment_out,
+ 0,
+ R.anim.anim_pop_settings_fragment_out
+ )
+ }
+ transaction.addToBackStack(null)
+ }
+ transaction.replace(
+ R.id.frame_content,
+ SettingsFragment.newInstance(menuTag, gameId),
+ FRAGMENT_TAG
+ )
+ transaction.commit()
+ }
+
+ private fun areSystemAnimationsEnabled(): Boolean {
+ val duration = android.provider.Settings.Global.getFloat(
+ contentResolver,
+ android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f
+ )
+ val transition = android.provider.Settings.Global.getFloat(
+ contentResolver,
+ android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f
+ )
+ return duration != 0f && transition != 0f
+ }
+
+ override fun onSettingsFileLoaded() {
+ val fragment: SettingsFragmentView? = settingsFragment
+ fragment?.loadSettingsList()
+ }
+
+ override fun onSettingsFileNotFound() {
+ val fragment: SettingsFragmentView? = settingsFragment
+ fragment?.loadSettingsList()
+ }
+
+ override fun showToastMessage(message: String, is_long: Boolean) {
+ Toast.makeText(
+ this,
+ message,
+ if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
+ ).show()
+ }
+
+ override fun onSettingChanged() {
+ presenter.onSettingChanged()
+ }
+
+ fun onSettingsReset() {
+ // Prevents saving to a non-existent settings file
+ presenter.onSettingsReset()
+
+ // Reset the static memory representation of each setting
+ BooleanSetting.clear()
+ FloatSetting.clear()
+ IntSetting.clear()
+ StringSetting.clear()
+
+ // Delete settings file because the user may have changed values that do not exist in the UI
+ val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
+ if (!settingsFile.delete()) {
+ throw IOException("Failed to delete $settingsFile")
+ }
+
+ showToastMessage(getString(R.string.settings_reset), true)
+ finish()
+ }
+
+ fun setToolbarTitle(title: String) {
+ binding.toolbarSettingsLayout.title = title
+ }
+
+ private val settingsFragment: SettingsFragment?
+ get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment?
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ view.updatePadding(
+ left = barInsets.left + cutoutInsets.left,
+ right = barInsets.right + cutoutInsets.right
+ )
+
+ val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left
+ mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right
+ binding.appbarSettings.layoutParams = mlpAppBar
+
+ val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
+ mlpShade.height = barInsets.bottom
+ binding.navigationBarShade.layoutParams = mlpShade
+
+ windowInsets
+ }
+ }
+
+ companion object {
+ private const val ARG_MENU_TAG = "menu_tag"
+ private const val ARG_GAME_ID = "game_id"
+ private const val FRAGMENT_TAG = "settings"
+
+ fun launch(context: Context, menuTag: String?, gameId: String?) {
+ val settings = Intent(context, SettingsActivity::class.java)
+ settings.putExtra(ARG_MENU_TAG, menuTag)
+ settings.putExtra(ARG_GAME_ID, gameId)
+ context.startActivity(settings)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt
new file mode 100644
index 000000000..4361d95fb
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityPresenter.kt
@@ -0,0 +1,84 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.text.TextUtils
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.Log
+import java.io.File
+
+class SettingsActivityPresenter(private val activityView: SettingsActivityView) {
+ val settings: Settings get() = activityView.settings
+
+ private var shouldSave = false
+ private lateinit var menuTag: String
+ private lateinit var gameId: String
+
+ fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
+ this.menuTag = menuTag
+ this.gameId = gameId
+ if (savedInstanceState != null) {
+ shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
+ }
+ }
+
+ fun onStart() {
+ prepareDirectoriesIfNeeded()
+ }
+
+ private fun loadSettingsUI() {
+ if (!settings.isLoaded) {
+ if (!TextUtils.isEmpty(gameId)) {
+ settings.loadSettings(gameId, activityView)
+ } else {
+ settings.loadSettings(activityView)
+ }
+ }
+ activityView.showSettingsFragment(menuTag, false, gameId)
+ activityView.onSettingsFileLoaded()
+ }
+
+ private fun prepareDirectoriesIfNeeded() {
+ val configFile =
+ File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
+ if (!configFile.exists()) {
+ Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
+ Log.error("yuzu config file could not be found!")
+ }
+
+ if (!DirectoryInitialization.areDirectoriesReady) {
+ DirectoryInitialization.start(activityView as Context)
+ }
+ loadSettingsUI()
+ }
+
+ fun onStop(finishing: Boolean) {
+ if (finishing && shouldSave) {
+ Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
+ settings.saveSettings(activityView)
+ }
+ NativeLibrary.reloadSettings()
+ }
+
+ fun onSettingChanged() {
+ shouldSave = true
+ }
+
+ fun onSettingsReset() {
+ shouldSave = false
+ }
+
+ fun saveState(outState: Bundle) {
+ outState.putBoolean(KEY_SHOULD_SAVE, shouldSave)
+ }
+
+ companion object {
+ private const val KEY_SHOULD_SAVE = "should_save"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt
new file mode 100644
index 000000000..c186fc388
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsActivityView.kt
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+
+/**
+ * Abstraction for the Activity that manages SettingsFragments.
+ */
+interface SettingsActivityView {
+ /**
+ * Show a new SettingsFragment.
+ *
+ * @param menuTag Identifier for the settings group that should be displayed.
+ * @param addToStack Whether or not this fragment should replace a previous one.
+ */
+ fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String)
+
+ /**
+ * Called by a contained Fragment to get access to the Setting HashMap
+ * loaded from disk, so that each Fragment doesn't need to perform its own
+ * read operation.
+ *
+ * @return A HashMap of Settings.
+ */
+ val settings: Settings
+
+ /**
+ * Called when a load operation completes.
+ */
+ fun onSettingsFileLoaded()
+
+ /**
+ * Called when a load operation fails.
+ */
+ fun onSettingsFileNotFound()
+
+ /**
+ * Display a popup text message on screen.
+ *
+ * @param message The contents of the onscreen message.
+ * @param is_long Whether this should be a long Toast or short one.
+ */
+ fun showToastMessage(message: String, is_long: Boolean)
+
+ /**
+ * End the activity.
+ */
+ fun finish()
+
+ /**
+ * Called by a containing Fragment to tell the Activity that a setting was changed;
+ * unless this has been called, the Activity will not save to disk.
+ */
+ fun onSettingChanged()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
new file mode 100644
index 000000000..1eb4899fc
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsAdapter.kt
@@ -0,0 +1,340 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.content.DialogInterface
+import android.icu.util.Calendar
+import android.icu.util.TimeZone
+import android.text.format.DateFormat
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.appcompat.app.AlertDialog
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.setFragmentResultListener
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.slider.Slider
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
+import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
+import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.*
+import org.yuzu.yuzu_emu.features.settings.ui.viewholder.*
+
+class SettingsAdapter(
+ private val fragmentView: SettingsFragmentView,
+ private val context: Context
+) : RecyclerView.Adapter(), DialogInterface.OnClickListener {
+ private var settings: ArrayList? = null
+ private var clickedItem: SettingsItem? = null
+ private var clickedPosition: Int
+ private var dialog: AlertDialog? = null
+ private var sliderProgress = 0
+ private var textSliderValue: TextView? = null
+
+ private var defaultCancelListener =
+ DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() }
+
+ init {
+ clickedPosition = -1
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
+ val inflater = LayoutInflater.from(parent.context)
+ return when (viewType) {
+ SettingsItem.TYPE_HEADER -> {
+ HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SWITCH -> {
+ SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> {
+ SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SLIDER -> {
+ SliderViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_SUBMENU -> {
+ SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_DATETIME_SETTING -> {
+ DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ SettingsItem.TYPE_RUNNABLE -> {
+ RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this)
+ }
+
+ else -> {
+ // TODO: Create an error view since we can't return null now
+ HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
+ }
+ }
+ }
+
+ override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ private fun getItem(position: Int): SettingsItem {
+ return settings!![position]
+ }
+
+ override fun getItemCount(): Int {
+ return if (settings != null) {
+ settings!!.size
+ } else {
+ 0
+ }
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return getItem(position).type
+ }
+
+ fun setSettingsList(settings: ArrayList?) {
+ this.settings = settings
+ notifyDataSetChanged()
+ }
+
+ fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) {
+ val setting = item.setChecked(checked)
+ fragmentView.putSetting(setting)
+ fragmentView.onSettingChanged()
+ }
+
+ private fun onSingleChoiceClick(item: SingleChoiceSetting) {
+ clickedItem = item
+ val value = getSelectionForSingleChoiceValue(item)
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setSingleChoiceItems(item.choicesId, value, this)
+ .show()
+ }
+
+ fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
+ clickedPosition = position
+ onSingleChoiceClick(item)
+ }
+
+ private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
+ clickedItem = item
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setSingleChoiceItems(item.choicesId, item.selectValueIndex, this)
+ .show()
+ }
+
+ fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) {
+ clickedPosition = position
+ onStringSingleChoiceClick(item)
+ }
+
+ fun onDateTimeClick(item: DateTimeSetting, position: Int) {
+ clickedItem = item
+ clickedPosition = position
+ val storedTime = java.lang.Long.decode(item.value) * 1000
+
+ // Helper to extract hour and minute from epoch time
+ val calendar: Calendar = Calendar.getInstance()
+ calendar.timeInMillis = storedTime
+ calendar.timeZone = TimeZone.getTimeZone("UTC")
+
+ var timeFormat: Int = TimeFormat.CLOCK_12H
+ if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) {
+ timeFormat = TimeFormat.CLOCK_24H
+ }
+
+ val datePicker: MaterialDatePicker = MaterialDatePicker.Builder.datePicker()
+ .setSelection(storedTime)
+ .setTitleText(R.string.select_rtc_date)
+ .build()
+ val timePicker: MaterialTimePicker = MaterialTimePicker.Builder()
+ .setTimeFormat(timeFormat)
+ .setHour(calendar.get(Calendar.HOUR_OF_DAY))
+ .setMinute(calendar.get(Calendar.MINUTE))
+ .setTitleText(R.string.select_rtc_time)
+ .build()
+
+ datePicker.addOnPositiveButtonClickListener {
+ timePicker.show(
+ (fragmentView.activityView as AppCompatActivity).supportFragmentManager,
+ "TimePicker"
+ )
+ }
+ timePicker.addOnPositiveButtonClickListener {
+ var epochTime: Long = datePicker.selection!! / 1000
+ epochTime += timePicker.hour.toLong() * 60 * 60
+ epochTime += timePicker.minute.toLong() * 60
+ val rtcString = epochTime.toString()
+ if (item.value != rtcString) {
+ fragmentView.onSettingChanged()
+ }
+ notifyItemChanged(clickedPosition)
+ val setting = item.setSelectedValue(rtcString)
+ fragmentView.putSetting(setting)
+ clickedItem = null
+ }
+ datePicker.show(
+ (fragmentView.activityView as AppCompatActivity).supportFragmentManager,
+ "DatePicker"
+ )
+ }
+
+ fun onSliderClick(item: SliderSetting, position: Int) {
+ clickedItem = item
+ clickedPosition = position
+ sliderProgress = item.selectedValue
+
+ val inflater = LayoutInflater.from(context)
+ val sliderBinding = DialogSliderBinding.inflate(inflater)
+
+ textSliderValue = sliderBinding.textValue
+ textSliderValue!!.text = sliderProgress.toString()
+ sliderBinding.textUnits.text = item.units
+
+ sliderBinding.slider.apply {
+ valueFrom = item.min.toFloat()
+ valueTo = item.max.toFloat()
+ value = sliderProgress.toFloat()
+ addOnChangeListener { _: Slider, value: Float, _: Boolean ->
+ sliderProgress = value.toInt()
+ textSliderValue!!.text = sliderProgress.toString()
+ }
+ }
+
+ dialog = MaterialAlertDialogBuilder(context)
+ .setTitle(item.nameId)
+ .setView(sliderBinding.root)
+ .setPositiveButton(android.R.string.ok, this)
+ .setNegativeButton(android.R.string.cancel, defaultCancelListener)
+ .setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int ->
+ sliderBinding.slider.value = item.defaultValue!!.toFloat()
+ onClick(dialog, which)
+ }
+ .show()
+ }
+
+ fun onSubmenuClick(item: SubmenuSetting) {
+ fragmentView.loadSubMenu(item.menuKey)
+ }
+
+ override fun onClick(dialog: DialogInterface, which: Int) {
+ when (clickedItem) {
+ is SingleChoiceSetting -> {
+ val scSetting = clickedItem as SingleChoiceSetting
+ val value = getValueForSingleChoiceSelection(scSetting, which)
+ if (scSetting.selectedValue != value) {
+ fragmentView.onSettingChanged()
+ }
+
+ // Get the backing Setting, which may be null (if for example it was missing from the file)
+ val setting = scSetting.setSelectedValue(value)
+ fragmentView.putSetting(setting)
+ closeDialog()
+ }
+
+ is StringSingleChoiceSetting -> {
+ val scSetting = clickedItem as StringSingleChoiceSetting
+ val value = scSetting.getValueAt(which)
+ if (scSetting.selectedValue != value) fragmentView.onSettingChanged()
+ val setting = scSetting.setSelectedValue(value!!)
+ fragmentView.putSetting(setting)
+ closeDialog()
+ }
+
+ is SliderSetting -> {
+ val sliderSetting = clickedItem as SliderSetting
+ if (sliderSetting.selectedValue != sliderProgress) {
+ fragmentView.onSettingChanged()
+ }
+ if (sliderSetting.setting is FloatSetting) {
+ val value = sliderProgress.toFloat()
+ val setting = sliderSetting.setSelectedValue(value)
+ fragmentView.putSetting(setting)
+ } else {
+ val setting = sliderSetting.setSelectedValue(sliderProgress)
+ fragmentView.putSetting(setting)
+ }
+ closeDialog()
+ }
+ }
+ clickedItem = null
+ sliderProgress = -1
+ }
+
+ fun onLongClick(setting: AbstractSetting, position: Int): Boolean {
+ MaterialAlertDialogBuilder(context)
+ .setMessage(R.string.reset_setting_confirmation)
+ .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int ->
+ when (setting) {
+ is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
+ is AbstractFloatSetting -> setting.float = setting.defaultValue as Float
+ is AbstractIntSetting -> setting.int = setting.defaultValue as Int
+ is AbstractStringSetting -> setting.string = setting.defaultValue as String
+ }
+ notifyItemChanged(position)
+ fragmentView.onSettingChanged()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+
+ return true
+ }
+
+ fun closeDialog() {
+ if (dialog != null) {
+ if (clickedPosition != -1) {
+ notifyItemChanged(clickedPosition)
+ clickedPosition = -1
+ }
+ dialog!!.dismiss()
+ dialog = null
+ }
+ }
+
+ private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
+ val valuesId = item.valuesId
+ return if (valuesId > 0) {
+ val valuesArray = context.resources.getIntArray(valuesId)
+ valuesArray[which]
+ } else {
+ which
+ }
+ }
+
+ private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int {
+ val value = item.selectedValue
+ val valuesId = item.valuesId
+ if (valuesId > 0) {
+ val valuesArray = context.resources.getIntArray(valuesId)
+ for (index in valuesArray.indices) {
+ val current = valuesArray[index]
+ if (current == value) {
+ return index
+ }
+ }
+ } else {
+ return value
+ }
+ return -1
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
new file mode 100644
index 000000000..867147950
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragment.kt
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.Context
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.divider.MaterialDividerItemDecoration
+import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+
+class SettingsFragment : Fragment(), SettingsFragmentView {
+ override var activityView: SettingsActivityView? = null
+
+ private val fragmentPresenter = SettingsFragmentPresenter(this)
+ private var settingsAdapter: SettingsAdapter? = null
+
+ private var _binding: FragmentSettingsBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ activityView = requireActivity() as SettingsActivityView
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG)
+ val gameId = requireArguments().getString(ARGUMENT_GAME_ID)
+ fragmentPresenter.onCreate(menuTag!!, gameId!!)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSettingsBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ settingsAdapter = SettingsAdapter(this, requireActivity())
+ val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
+ dividerDecoration.isLastItemDecorated = false
+ binding.listSettings.apply {
+ adapter = settingsAdapter
+ layoutManager = LinearLayoutManager(activity)
+ addItemDecoration(dividerDecoration)
+ }
+ fragmentPresenter.onViewCreated()
+
+ setInsets()
+ }
+
+ override fun onDetach() {
+ super.onDetach()
+ activityView = null
+ if (settingsAdapter != null) {
+ settingsAdapter!!.closeDialog()
+ }
+ }
+
+ override fun showSettingsList(settingsList: ArrayList) {
+ settingsAdapter!!.setSettingsList(settingsList)
+ }
+
+ override fun loadSettingsList() {
+ fragmentPresenter.loadSettingsList()
+ }
+
+ override fun loadSubMenu(menuKey: String) {
+ activityView!!.showSettingsFragment(
+ menuKey,
+ true,
+ requireArguments().getString(ARGUMENT_GAME_ID)!!
+ )
+ }
+
+ override fun showToastMessage(message: String?, is_long: Boolean) {
+ activityView!!.showToastMessage(message!!, is_long)
+ }
+
+ override fun putSetting(setting: AbstractSetting) {
+ fragmentPresenter.putSetting(setting)
+ }
+
+ override fun onSettingChanged() {
+ activityView!!.onSettingChanged()
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ view.updatePadding(bottom = insets.bottom)
+ windowInsets
+ }
+ }
+
+ companion object {
+ private const val ARGUMENT_MENU_TAG = "menu_tag"
+ private const val ARGUMENT_GAME_ID = "game_id"
+
+ fun newInstance(menuTag: String?, gameId: String?): Fragment {
+ val fragment = SettingsFragment()
+ val arguments = Bundle()
+ arguments.putString(ARGUMENT_MENU_TAG, menuTag)
+ arguments.putString(ARGUMENT_GAME_ID, gameId)
+ fragment.arguments = arguments
+ return fragment
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
new file mode 100644
index 000000000..061046b2e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentPresenter.kt
@@ -0,0 +1,465 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import android.content.SharedPreferences
+import android.os.Build
+import android.text.TextUtils
+import androidx.preference.PreferenceManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.StringSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.*
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
+import org.yuzu.yuzu_emu.utils.ThemeHelper
+
+class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) {
+ private var menuTag: String? = null
+ private lateinit var gameId: String
+ private var settingsList: ArrayList? = null
+
+ private val settingsActivity get() = fragmentView.activityView as SettingsActivity
+ private val settings get() = fragmentView.activityView!!.settings
+
+ private lateinit var preferences: SharedPreferences
+
+ fun onCreate(menuTag: String, gameId: String) {
+ this.gameId = gameId
+ this.menuTag = menuTag
+ }
+
+ fun onViewCreated() {
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ loadSettingsList()
+ }
+
+ fun putSetting(setting: AbstractSetting) {
+ if (setting.section == null) {
+ return
+ }
+
+ val section = settings.getSection(setting.section!!)!!
+ if (section.getSetting(setting.key!!) == null) {
+ section.putSetting(setting)
+ }
+ }
+
+ fun loadSettingsList() {
+ if (!TextUtils.isEmpty(gameId)) {
+ settingsActivity.setToolbarTitle("Game Settings: $gameId")
+ }
+ val sl = ArrayList()
+ if (menuTag == null) {
+ return
+ }
+ when (menuTag) {
+ SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl)
+ Settings.SECTION_GENERAL -> addGeneralSettings(sl)
+ Settings.SECTION_SYSTEM -> addSystemSettings(sl)
+ Settings.SECTION_RENDERER -> addGraphicsSettings(sl)
+ Settings.SECTION_AUDIO -> addAudioSettings(sl)
+ Settings.SECTION_THEME -> addThemeSettings(sl)
+ Settings.SECTION_DEBUG -> addDebugSettings(sl)
+ else -> {
+ fragmentView.showToastMessage("Unimplemented menu", false)
+ return
+ }
+ }
+ settingsList = sl
+ fragmentView.showSettingsList(settingsList!!)
+ }
+
+ private fun addConfigSettings(sl: ArrayList) {
+ 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) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general))
+ sl.apply {
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_USE_SPEED_LIMIT,
+ R.string.frame_limit_enable,
+ R.string.frame_limit_enable_description,
+ IntSetting.RENDERER_USE_SPEED_LIMIT.key,
+ IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue
+ )
+ )
+ add(
+ SliderSetting(
+ IntSetting.RENDERER_SPEED_LIMIT,
+ R.string.frame_limit_slider,
+ R.string.frame_limit_slider_description,
+ 1,
+ 200,
+ "%",
+ IntSetting.RENDERER_SPEED_LIMIT.key,
+ IntSetting.RENDERER_SPEED_LIMIT.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.CPU_ACCURACY,
+ R.string.cpu_accuracy,
+ 0,
+ R.array.cpuAccuracyNames,
+ R.array.cpuAccuracyValues,
+ IntSetting.CPU_ACCURACY.key,
+ IntSetting.CPU_ACCURACY.defaultValue
+ )
+ )
+ }
+ }
+
+ private fun addSystemSettings(sl: ArrayList) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system))
+ sl.apply {
+ add(
+ SwitchSetting(
+ IntSetting.USE_DOCKED_MODE,
+ R.string.use_docked_mode,
+ R.string.use_docked_mode_description,
+ IntSetting.USE_DOCKED_MODE.key,
+ IntSetting.USE_DOCKED_MODE.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.REGION_INDEX,
+ R.string.emulated_region,
+ 0,
+ R.array.regionNames,
+ R.array.regionValues,
+ IntSetting.REGION_INDEX.key,
+ IntSetting.REGION_INDEX.defaultValue
+ )
+ )
+ add(
+ SingleChoiceSetting(
+ IntSetting.LANGUAGE_INDEX,
+ R.string.emulated_language,
+ 0,
+ R.array.languageNames,
+ R.array.languageValues,
+ IntSetting.LANGUAGE_INDEX.key,
+ IntSetting.LANGUAGE_INDEX.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ BooleanSetting.USE_CUSTOM_RTC,
+ R.string.use_custom_rtc,
+ R.string.use_custom_rtc_description,
+ BooleanSetting.USE_CUSTOM_RTC.key,
+ BooleanSetting.USE_CUSTOM_RTC.defaultValue
+ )
+ )
+ add(
+ DateTimeSetting(
+ StringSetting.CUSTOM_RTC,
+ R.string.set_custom_rtc,
+ 0,
+ StringSetting.CUSTOM_RTC.key,
+ StringSetting.CUSTOM_RTC.defaultValue
+ )
+ )
+ }
+ }
+
+ private fun addGraphicsSettings(sl: ArrayList) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics))
+ sl.apply {
+
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_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) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio))
+ sl.add(
+ SliderSetting(
+ IntSetting.AUDIO_VOLUME,
+ R.string.audio_volume,
+ R.string.audio_volume_description,
+ 0,
+ 100,
+ "%",
+ IntSetting.AUDIO_VOLUME.key,
+ IntSetting.AUDIO_VOLUME.defaultValue
+ )
+ )
+ }
+
+ private fun addThemeSettings(sl: ArrayList) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme))
+ sl.apply {
+ val theme: AbstractIntSetting = object : AbstractIntSetting {
+ override var int: Int
+ get() = preferences.getInt(Settings.PREF_THEME, 0)
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_THEME, value)
+ .apply()
+ settingsActivity.recreate()
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getInt(Settings.PREF_THEME, 0).toString()
+ override val defaultValue: Any = 0
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ add(
+ SingleChoiceSetting(
+ theme,
+ R.string.change_app_theme,
+ 0,
+ R.array.themeEntriesA12,
+ R.array.themeValuesA12
+ )
+ )
+ } else {
+ add(
+ SingleChoiceSetting(
+ theme,
+ R.string.change_app_theme,
+ 0,
+ R.array.themeEntries,
+ R.array.themeValues
+ )
+ )
+ }
+
+ val themeMode: AbstractIntSetting = object : AbstractIntSetting {
+ override var int: Int
+ get() = preferences.getInt(Settings.PREF_THEME_MODE, -1)
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_THEME_MODE, value)
+ .apply()
+ ThemeHelper.setThemeMode(settingsActivity)
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString()
+ override val defaultValue: Any = -1
+ }
+
+ add(
+ SingleChoiceSetting(
+ themeMode,
+ R.string.change_theme_mode,
+ 0,
+ R.array.themeModeEntries,
+ R.array.themeModeValues
+ )
+ )
+
+ val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting {
+ override var boolean: Boolean
+ get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value)
+ .apply()
+ settingsActivity.recreate()
+ }
+ override val key: String? = null
+ override val section: String? = null
+ override val isRuntimeEditable: Boolean = false
+ override val valueAsString: String
+ get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ .toString()
+ override val defaultValue: Any = false
+ }
+
+ add(
+ SwitchSetting(
+ blackBackgrounds,
+ R.string.use_black_backgrounds,
+ R.string.use_black_backgrounds_description
+ )
+ )
+ }
+ }
+
+ private fun addDebugSettings(sl: ArrayList) {
+ settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_debug))
+ sl.apply {
+ add(
+ SingleChoiceSetting(
+ IntSetting.RENDERER_BACKEND,
+ R.string.renderer_api,
+ 0,
+ R.array.rendererApiNames,
+ R.array.rendererApiValues,
+ IntSetting.RENDERER_BACKEND.key,
+ IntSetting.RENDERER_BACKEND.defaultValue
+ )
+ )
+ add(
+ SwitchSetting(
+ IntSetting.RENDERER_DEBUG,
+ R.string.renderer_debug,
+ R.string.renderer_debug_description,
+ IntSetting.RENDERER_DEBUG.key,
+ IntSetting.RENDERER_DEBUG.defaultValue
+ )
+ )
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt
new file mode 100644
index 000000000..1ebe35eaa
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/SettingsFragmentView.kt
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui
+
+import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+
+/**
+ * Abstraction for a screen showing a list of settings. Instances of
+ * this type of view will each display a layer of the setting hierarchy.
+ */
+interface SettingsFragmentView {
+ /**
+ * Pass an ArrayList to the View so that it can be displayed on screen.
+ *
+ * @param settingsList The result of converting the HashMap to an ArrayList
+ */
+ fun showSettingsList(settingsList: ArrayList)
+
+ /**
+ * Instructs the Fragment to load the settings screen.
+ */
+ fun loadSettingsList()
+
+ /**
+ * @return The Fragment's containing activity.
+ */
+ val activityView: SettingsActivityView?
+
+ /**
+ * Tell the Fragment to tell the containing Activity to show a new
+ * Fragment containing a submenu of settings.
+ *
+ * @param menuKey Identifier for the settings group that should be shown.
+ */
+ fun loadSubMenu(menuKey: String)
+
+ /**
+ * Tell the Fragment to tell the containing activity to display a toast message.
+ *
+ * @param message Text to be shown in the Toast
+ * @param is_long Whether this should be a long Toast or short one.
+ */
+ fun showToastMessage(message: String?, is_long: Boolean)
+
+ /**
+ * Have the fragment add a setting to the HashMap.
+ *
+ * @param setting The (possibly previously missing) new setting.
+ */
+ fun putSetting(setting: AbstractSetting)
+
+ /**
+ * Have the fragment tell the containing Activity that a setting was modified.
+ */
+ fun onSettingChanged()
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
new file mode 100644
index 000000000..04c045e77
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/DateTimeViewHolder.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
+
+class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: DateTimeSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as DateTimeSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ val epochTime = setting.value.toLong()
+ val instant = Instant.ofEpochMilli(epochTime * 1000)
+ val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))
+ val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+ binding.textSettingDescription.text = dateFormatter.format(zonedTime)
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ adapter.onDateTimeClick(setting, bindingAdapterPosition)
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt
new file mode 100644
index 000000000..f5bcf705c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/HeaderViewHolder.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ init {
+ itemView.setOnClickListener(null)
+ }
+
+ override fun bind(item: SettingsItem) {
+ binding.textHeaderName.setText(item.nameId)
+ }
+
+ override fun onClick(clicked: View) {
+ // no-op
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt
new file mode 100644
index 000000000..5dad5945f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/RunnableViewHolder.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: RunnableSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as RunnableSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (!setting.isRuntimeRunnable && !NativeLibrary.isRunning()) {
+ setting.runnable.invoke()
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
new file mode 100644
index 000000000..f56460893
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SettingViewHolder.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
+ RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
+
+ init {
+ itemView.setOnClickListener(this)
+ itemView.setOnLongClickListener(this)
+ }
+
+ /**
+ * Called by the adapter to set this ViewHolder's child views to display the list item
+ * it must now represent.
+ *
+ * @param item The list item that should be represented by this ViewHolder.
+ */
+ abstract fun bind(item: SettingsItem)
+
+ /**
+ * Called when this ViewHolder's view is clicked on. Implementations should usually pass
+ * this event up to the adapter.
+ *
+ * @param clicked The view that was clicked on.
+ */
+ abstract override fun onClick(clicked: View)
+
+ abstract override fun onLongClick(clicked: View): Boolean
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
new file mode 100644
index 000000000..de764a27f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.kt
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: SettingsItem
+
+ override fun bind(item: SettingsItem) {
+ setting = item
+ binding.textSettingName.setText(item.nameId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ } else if (item is SingleChoiceSetting) {
+ val resMgr = binding.textSettingDescription.context.resources
+ val values = resMgr.getIntArray(item.valuesId)
+ for (i in values.indices) {
+ if (values[i] == item.selectedValue) {
+ binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i]
+ }
+ }
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (!setting.isEditable) {
+ return
+ }
+
+ if (setting is SingleChoiceSetting) {
+ adapter.onSingleChoiceClick(
+ (setting as SingleChoiceSetting),
+ bindingAdapterPosition
+ )
+ } else if (setting is StringSingleChoiceSetting) {
+ adapter.onStringSingleChoiceClick(
+ (setting as StringSingleChoiceSetting),
+ bindingAdapterPosition
+ )
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
new file mode 100644
index 000000000..cc3f39aa5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SliderViewHolder.kt
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var setting: SliderSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as SliderSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ adapter.onSliderClick(setting, bindingAdapterPosition)
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
new file mode 100644
index 000000000..c545b4174
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SubmenuViewHolder.kt
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+ private lateinit var item: SubmenuSetting
+
+ override fun bind(item: SettingsItem) {
+ this.item = item as SubmenuSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ }
+
+ override fun onClick(clicked: View) {
+ adapter.onSubmenuClick(item)
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ // no-op
+ return true
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
new file mode 100644
index 000000000..b163bd6ca
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/ui/viewholder/SwitchSettingViewHolder.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.ui.viewholder
+
+import android.view.View
+import android.widget.CompoundButton
+import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
+import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
+import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
+
+class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
+ SettingViewHolder(binding.root, adapter) {
+
+ private lateinit var setting: SwitchSetting
+
+ override fun bind(item: SettingsItem) {
+ setting = item as SwitchSetting
+ binding.textSettingName.setText(item.nameId)
+ if (item.descriptionId != 0) {
+ binding.textSettingDescription.setText(item.descriptionId)
+ binding.textSettingDescription.visibility = View.VISIBLE
+ } else {
+ binding.textSettingDescription.text = ""
+ binding.textSettingDescription.visibility = View.GONE
+ }
+ binding.switchWidget.isChecked = setting.isChecked
+ binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
+ adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked)
+ }
+
+ binding.switchWidget.isEnabled = setting.isEditable
+ }
+
+ override fun onClick(clicked: View) {
+ if (setting.isEditable) {
+ binding.switchWidget.toggle()
+ }
+ }
+
+ override fun onLongClick(clicked: View): Boolean {
+ if (setting.isEditable) {
+ return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
+ }
+ return false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
new file mode 100644
index 000000000..e29bca11d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/features/settings/utils/SettingsFile.kt
@@ -0,0 +1,241 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.features.settings.utils
+
+import org.ini4j.Wini
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.*
+import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
+import org.yuzu.yuzu_emu.utils.BiMap
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.Log
+import java.io.*
+import java.util.*
+
+/**
+ * Contains static methods for interacting with .ini files in which settings are stored.
+ */
+object SettingsFile {
+ const val FILE_NAME_CONFIG = "config"
+
+ private var sectionsMap = BiMap()
+
+ /**
+ * 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 {
+ val sections: HashMap = SettingsSectionMap()
+ var reader: BufferedReader? = null
+ try {
+ reader = BufferedReader(FileReader(ini))
+ var current: SettingSection? = null
+ var line: String?
+ while (reader.readLine().also { line = it } != null) {
+ if (line!!.startsWith("[") && line!!.endsWith("]")) {
+ current = sectionFromLine(line!!, isCustomGame)
+ sections[current.name] = current
+ } else if (current != null) {
+ val setting = settingFromLine(line!!)
+ if (setting != null) {
+ current.putSetting(setting)
+ }
+ }
+ }
+ } catch (e: FileNotFoundException) {
+ Log.error("[SettingsFile] File not found: " + e.message)
+ view?.onSettingsFileNotFound()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] Error reading from: " + e.message)
+ view?.onSettingsFileNotFound()
+ } finally {
+ if (reader != null) {
+ try {
+ reader.close()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] Error closing: " + e.message)
+ }
+ }
+ }
+ return sections
+ }
+
+ fun readFile(fileName: String, view: SettingsActivityView?): HashMap {
+ return readFile(getSettingsFile(fileName), false, view)
+ }
+
+ fun readFile(fileName: String): HashMap =
+ 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 {
+ return readFile(getCustomGameSettingsFile(gameId), true, view)
+ }
+
+ /**
+ * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error
+ * telling why it failed.
+ *
+ * @param fileName The target filename without a path or extension.
+ * @param sections The HashMap containing the Settings we want to serialize.
+ * @param view The current view.
+ */
+ fun saveFile(
+ fileName: String,
+ sections: TreeMap,
+ view: SettingsActivityView
+ ) {
+ val ini = getSettingsFile(fileName)
+ try {
+ val writer = Wini(ini)
+ val keySet: Set = sections.keys
+ for (key in keySet) {
+ val section = sections[key]
+ writeSection(writer, section!!)
+ }
+ writer.store()
+ } catch (e: IOException) {
+ Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message)
+ view.showToastMessage(
+ YuzuApplication.appContext
+ .getString(R.string.error_saving, fileName, e.message),
+ false
+ )
+ }
+ }
+
+ fun saveCustomGameSettings(gameId: String?, sections: HashMap) {
+ val sortedSections: Set = TreeSet(sections.keys)
+ for (sectionKey in sortedSections) {
+ val section = sections[sectionKey]
+ val settings = section!!.settings
+ val sortedKeySet: Set = TreeSet(settings.keys)
+ for (settingKey in sortedKeySet) {
+ val setting = settings[settingKey]
+ NativeLibrary.setUserSetting(
+ gameId, mapSectionNameFromIni(
+ section.name
+ ), setting!!.key, setting.valueAsString
+ )
+ }
+ }
+ }
+
+ private fun mapSectionNameFromIni(generalSectionName: String): String? {
+ return if (sectionsMap.getForward(generalSectionName) != null) {
+ sectionsMap.getForward(generalSectionName)
+ } else generalSectionName
+ }
+
+ private fun mapSectionNameToIni(generalSectionName: String): String {
+ return if (sectionsMap.getBackward(generalSectionName) != null) {
+ sectionsMap.getBackward(generalSectionName).toString()
+ } else generalSectionName
+ }
+
+ fun getSettingsFile(fileName: String): File {
+ return File(
+ DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini"
+ )
+ }
+
+ private fun getCustomGameSettingsFile(gameId: String): File {
+ return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini")
+ }
+
+ private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
+ var sectionName: String = line.substring(1, line.length - 1)
+ if (isCustomGame) {
+ sectionName = mapSectionNameToIni(sectionName)
+ }
+ return SettingSection(sectionName)
+ }
+
+ /**
+ * For a line of text, determines what type of data is being represented, and returns
+ * a Setting object containing this data.
+ *
+ * @param line The line of text being parsed.
+ * @return A typed Setting containing the key/value contained in the line.
+ */
+ private fun settingFromLine(line: String): AbstractSetting? {
+ val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ if (splitLine.size != 2) {
+ return null
+ }
+ val key = splitLine[0].trim { it <= ' ' }
+ val value = splitLine[1].trim { it <= ' ' }
+ if (value.isEmpty()) {
+ return null
+ }
+
+ val booleanSetting = BooleanSetting.from(key)
+ if (booleanSetting != null) {
+ booleanSetting.boolean = value.toBoolean()
+ return booleanSetting
+ }
+
+ val intSetting = IntSetting.from(key)
+ if (intSetting != null) {
+ intSetting.int = value.toInt()
+ return intSetting
+ }
+
+ val floatSetting = FloatSetting.from(key)
+ if (floatSetting != null) {
+ floatSetting.float = value.toFloat()
+ return floatSetting
+ }
+
+ val stringSetting = StringSetting.from(key)
+ if (stringSetting != null) {
+ stringSetting.string = value
+ return stringSetting
+ }
+
+ return null
+ }
+
+ /**
+ * Writes the contents of a Section HashMap to disk.
+ *
+ * @param parser A Wini pointed at a file on disk.
+ * @param section A section containing settings to be written to the file.
+ */
+ private fun writeSection(parser: Wini, section: SettingSection) {
+ // Write the section header.
+ val header = section.name
+
+ // Write this section's values.
+ val settings = section.settings
+ val keySet: Set = settings.keys
+ for (key in keySet) {
+ val setting = settings[key]
+ parser.put(header, setting!!.key, setting.valueAsString)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
new file mode 100644
index 000000000..c92e2755c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
@@ -0,0 +1,125 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.BuildConfig
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+
+class AboutFragment : Fragment() {
+ private var _binding: FragmentAboutBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAboutBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarAbout.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ binding.imageLogo.setOnLongClickListener {
+ Toast.makeText(
+ requireContext(),
+ R.string.gaia_is_not_real,
+ Toast.LENGTH_SHORT
+ ).show()
+ true
+ }
+
+ binding.buttonContributors.setOnClickListener { openLink(getString(R.string.contributors_link)) }
+ binding.buttonLicenses.setOnClickListener {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment)
+ }
+
+ binding.textBuildHash.text = BuildConfig.GIT_HASH
+ binding.buttonBuildHash.setOnClickListener {
+ val clipBoard =
+ requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH)
+ clipBoard.setPrimaryClip(clip)
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
+ Toast.makeText(
+ requireContext(),
+ R.string.copied_to_clipboard,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
+ binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
+ binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
+
+ setInsets()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarAbout.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.scrollAbout.layoutParams = mlpScrollAbout
+
+ binding.contentAbout.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt
new file mode 100644
index 000000000..d8bbc1ce4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EarlyAccessFragment.kt
@@ -0,0 +1,83 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+
+class EarlyAccessFragment : Fragment() {
+ private var _binding: FragmentEarlyAccessBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentEarlyAccessBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarAbout.setNavigationOnClickListener {
+ parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
+ }
+
+ binding.getEarlyAccessButton.setOnClickListener { openLink(getString(R.string.play_store_link)) }
+
+ setInsets()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarEa.layoutParams = mlpAppBar
+
+ binding.scrollEa.updatePadding(
+ left = leftInsets,
+ right = rightInsets,
+ bottom = barInsets.bottom
+ )
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
new file mode 100644
index 000000000..9523381cd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
@@ -0,0 +1,613 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.annotation.SuppressLint
+import android.app.AlertDialog
+import android.content.Context
+import android.content.DialogInterface
+import android.content.SharedPreferences
+import android.content.pm.ActivityInfo
+import android.content.res.Resources
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.util.Rational
+import android.util.TypedValue
+import android.view.*
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.widget.PopupMenu
+import androidx.core.content.res.ResourcesCompat
+import androidx.core.graphics.Insets
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.preference.PreferenceManager
+import androidx.window.layout.FoldingFeature
+import androidx.window.layout.WindowLayoutInfo
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.slider.Slider
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding
+import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
+import org.yuzu.yuzu_emu.features.settings.model.IntSetting
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.utils.*
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+
+class EmulationFragment : Fragment(), SurfaceHolder.Callback {
+ private lateinit var preferences: SharedPreferences
+ private lateinit var emulationState: EmulationState
+ private var emulationActivity: EmulationActivity? = null
+ private var perfStatsUpdater: (() -> Unit)? = null
+
+ private var _binding: FragmentEmulationBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var game: Game
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ if (context is EmulationActivity) {
+ emulationActivity = context
+ NativeLibrary.setEmulationActivity(context)
+ } else {
+ throw IllegalStateException("EmulationFragment must have EmulationActivity parent")
+ }
+ }
+
+ /**
+ * Initialize anything that doesn't depend on the layout / views in here.
+ */
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // So this fragment doesn't restart on configuration changes; i.e. rotation.
+ retainInstance = true
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!!
+ emulationState = EmulationState(game.path)
+ }
+
+ /**
+ * Initialize the UI and start emulation in here.
+ */
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentEmulationBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ binding.surfaceEmulation.holder.addCallback(this)
+ binding.showFpsText.setTextColor(Color.YELLOW)
+ binding.doneControlConfig.setOnClickListener { stopConfiguringControls() }
+
+ // Setup overlay.
+ updateShowFpsOverlay()
+
+ binding.inGameMenu.getHeaderView(0).findViewById(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(R.id.menu_overlay_controls)
+ val popup = PopupMenu(requireContext(), anchor)
+
+ popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu)
+
+ popup.menu.apply {
+ findItem(R.id.menu_toggle_fps).isChecked = EmulationMenuSettings.showFps
+ findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter
+ findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide
+ findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay
+ findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback
+ }
+
+ popup.setOnMenuItemClickListener {
+ when (it.itemId) {
+ R.id.menu_toggle_fps -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.showFps = it.isChecked
+ updateShowFpsOverlay()
+ true
+ }
+
+ R.id.menu_edit_overlay -> {
+ binding.drawerLayout.close()
+ binding.surfaceInputOverlay.requestFocus()
+ startConfiguringControls()
+ true
+ }
+
+ R.id.menu_adjust_overlay -> {
+ adjustOverlay()
+ true
+ }
+
+ R.id.menu_toggle_controls -> {
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ val optionsArray = BooleanArray(15)
+ for (i in 0..14) {
+ optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13)
+ }
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.emulation_toggle_controls)
+ .setMultiChoiceItems(
+ R.array.gamepadButtons,
+ optionsArray
+ ) { _, indexSelected, isChecked ->
+ preferences.edit()
+ .putBoolean("buttonToggle$indexSelected", isChecked)
+ .apply()
+ }
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ refreshInputOverlay()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> }
+ .show()
+
+ // Override normal behaviour so the dialog doesn't close
+ dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
+ .setOnClickListener {
+ val isChecked = !optionsArray[0]
+ for (i in 0..14) {
+ optionsArray[i] = isChecked
+ dialog.listView.setItemChecked(i, isChecked)
+ preferences.edit()
+ .putBoolean("buttonToggle$i", isChecked)
+ .apply()
+ }
+ }
+ true
+ }
+
+ R.id.menu_show_overlay -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.showOverlay = it.isChecked
+ refreshInputOverlay()
+ true
+ }
+
+ R.id.menu_rel_stick_center -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.joystickRelCenter = it.isChecked
+ true
+ }
+
+ R.id.menu_dpad_slide -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.dpadSlide = it.isChecked
+ true
+ }
+
+ R.id.menu_haptics -> {
+ it.isChecked = !it.isChecked
+ EmulationMenuSettings.hapticFeedback = it.isChecked
+ true
+ }
+
+ R.id.menu_reset_overlay -> {
+ binding.drawerLayout.close()
+ resetInputOverlay()
+ true
+ }
+
+ else -> true
+ }
+ }
+
+ popup.show()
+ }
+
+ private fun startConfiguringControls() {
+ binding.doneControlConfig.visibility = View.VISIBLE
+ binding.surfaceInputOverlay.setIsInEditMode(true)
+ }
+
+ private fun stopConfiguringControls() {
+ binding.doneControlConfig.visibility = View.GONE
+ binding.surfaceInputOverlay.setIsInEditMode(false)
+ }
+
+ @SuppressLint("SetTextI18n")
+ private fun adjustOverlay() {
+ val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater)
+ adjustBinding.apply {
+ inputScaleSlider.apply {
+ valueTo = 150F
+ value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat()
+ addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
+ inputScaleValue.text = "${value.toInt()}%"
+ setControlScale(value.toInt())
+ })
+ }
+ inputOpacitySlider.apply {
+ valueTo = 100F
+ value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).toFloat()
+ addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
+ inputOpacityValue.text = "${value.toInt()}%"
+ setControlOpacity(value.toInt())
+ })
+ }
+ inputScaleValue.text = "${inputScaleSlider.value.toInt()}%"
+ inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%"
+ }
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.emulation_control_adjust)
+ .setView(adjustBinding.root)
+ .setPositiveButton(android.R.string.ok, null)
+ .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int ->
+ setControlScale(50)
+ setControlOpacity(100)
+ }
+ .show()
+ }
+
+ private fun setControlScale(scale: Int) {
+ preferences.edit()
+ .putInt(Settings.PREF_CONTROL_SCALE, scale)
+ .apply()
+ refreshInputOverlay()
+ }
+
+ private fun setControlOpacity(opacity: Int) {
+ preferences.edit()
+ .putInt(Settings.PREF_CONTROL_OPACITY, opacity)
+ .apply()
+ refreshInputOverlay()
+ }
+
+ private fun setInsets() {
+ ViewCompat.setOnApplyWindowInsetsListener(binding.inGameMenu) { v: View, windowInsets: WindowInsetsCompat ->
+ val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ var left = 0
+ var right = 0
+ if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ left = cutInsets.left
+ } else {
+ right = cutInsets.right
+ }
+
+ v.setPadding(left, cutInsets.top, right, 0)
+
+ // Ensure FPS text doesn't get cut off by rounded display corners
+ val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
+ if (cutInsets.left == 0) {
+ binding.showFpsText.setPadding(
+ sidePadding,
+ cutInsets.top,
+ cutInsets.right,
+ cutInsets.bottom
+ )
+ } else {
+ binding.showFpsText.setPadding(
+ cutInsets.left,
+ cutInsets.top,
+ cutInsets.right,
+ cutInsets.bottom
+ )
+ }
+ windowInsets
+ }
+ }
+
+ private class EmulationState(private val gamePath: String) {
+ private var state: State
+ private var surface: Surface? = null
+ private var runWhenSurfaceIsValid = false
+
+ init {
+ // Starting state is stopped.
+ state = State.STOPPED
+ }
+
+ @get:Synchronized
+ val isStopped: Boolean
+ get() = state == State.STOPPED
+
+ // Getters for the current state
+ @get:Synchronized
+ val isPaused: Boolean
+ get() = state == State.PAUSED
+
+ @get:Synchronized
+ val isRunning: Boolean
+ get() = state == State.RUNNING
+
+ @Synchronized
+ fun stop() {
+ if (state != State.STOPPED) {
+ Log.debug("[EmulationFragment] Stopping emulation.")
+ NativeLibrary.stopEmulation()
+ state = State.STOPPED
+ } else {
+ Log.warning("[EmulationFragment] Stop called while already stopped.")
+ }
+ }
+
+ // State changing methods
+ @Synchronized
+ fun pause() {
+ if (state != State.PAUSED) {
+ Log.debug("[EmulationFragment] Pausing emulation.")
+
+ NativeLibrary.pauseEmulation()
+
+ state = State.PAUSED
+ } else {
+ Log.warning("[EmulationFragment] Pause called while already paused.")
+ }
+ }
+
+ @Synchronized
+ fun run(isActivityRecreated: Boolean) {
+ if (isActivityRecreated) {
+ if (NativeLibrary.isRunning()) {
+ state = State.PAUSED
+ }
+ } else {
+ Log.debug("[EmulationFragment] activity resumed or fresh start")
+ }
+
+ // If the surface is set, run now. Otherwise, wait for it to get set.
+ if (surface != null) {
+ runWithValidSurface()
+ } else {
+ runWhenSurfaceIsValid = true
+ }
+ }
+
+ // Surface callbacks
+ @Synchronized
+ fun newSurface(surface: Surface?) {
+ this.surface = surface
+ if (runWhenSurfaceIsValid) {
+ runWithValidSurface()
+ }
+ }
+
+ @Synchronized
+ fun clearSurface() {
+ if (surface == null) {
+ Log.warning("[EmulationFragment] clearSurface called, but surface already null.")
+ } else {
+ surface = null
+ Log.debug("[EmulationFragment] Surface destroyed.")
+ when (state) {
+ State.RUNNING -> {
+ state = State.PAUSED
+ }
+
+ State.PAUSED -> Log.warning("[EmulationFragment] Surface cleared while emulation paused.")
+ else -> Log.warning("[EmulationFragment] Surface cleared while emulation stopped.")
+ }
+ }
+ }
+
+ private fun runWithValidSurface() {
+ runWhenSurfaceIsValid = false
+ when (state) {
+ State.STOPPED -> {
+ NativeLibrary.surfaceChanged(surface)
+ val emulationThread = Thread({
+ Log.debug("[EmulationFragment] Starting emulation thread.")
+ NativeLibrary.run(gamePath)
+ }, "NativeEmulation")
+ emulationThread.start()
+ }
+
+ State.PAUSED -> {
+ Log.debug("[EmulationFragment] Resuming emulation.")
+ NativeLibrary.surfaceChanged(surface)
+ NativeLibrary.unPauseEmulation()
+ }
+
+ else -> Log.debug("[EmulationFragment] Bug, run called while already running.")
+ }
+ state = State.RUNNING
+ }
+
+ private enum class State {
+ STOPPED, RUNNING, PAUSED
+ }
+ }
+
+ companion object {
+ private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!)
+
+ fun newInstance(game: Game): EmulationFragment {
+ val args = Bundle()
+ args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game)
+ val fragment = EmulationFragment()
+ fragment.arguments = args
+ return fragment
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
new file mode 100644
index 000000000..bdc337501
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/HomeSettingsFragment.kt
@@ -0,0 +1,330 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.Manifest
+import android.content.ActivityNotFoundException
+import android.content.DialogInterface
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.documentfile.provider.DocumentFile
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.BuildConfig
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding
+import org.yuzu.yuzu_emu.features.DocumentProvider
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.model.HomeSetting
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.GpuDriverHelper
+
+class HomeSettingsFragment : Fragment() {
+ private var _binding: FragmentHomeSettingsBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var mainActivity: MainActivity
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentHomeSettingsBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ val optionsList: MutableList = mutableListOf(
+ HomeSetting(
+ R.string.advanced_settings,
+ R.string.settings_description,
+ R.drawable.ic_settings
+ ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") },
+ HomeSetting(
+ R.string.open_user_folder,
+ R.string.open_user_folder_description,
+ R.drawable.ic_folder_open
+ ) { openFileManager() },
+ HomeSetting(
+ R.string.preferences_theme,
+ R.string.theme_and_color_description,
+ R.drawable.ic_palette
+ ) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") },
+ HomeSetting(
+ R.string.install_gpu_driver,
+ R.string.install_gpu_driver_description,
+ R.drawable.ic_exit
+ ) { driverInstaller() },
+ HomeSetting(
+ R.string.install_amiibo_keys,
+ R.string.install_amiibo_keys_description,
+ R.drawable.ic_nfc
+ ) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) },
+ HomeSetting(
+ R.string.select_games_folder,
+ R.string.select_games_folder_description,
+ R.drawable.ic_add
+ ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+ HomeSetting(
+ R.string.manage_save_data,
+ R.string.import_export_saves_description,
+ R.drawable.ic_save
+ ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) },
+ HomeSetting(
+ R.string.install_prod_keys,
+ R.string.install_prod_keys_description,
+ R.drawable.ic_unlock
+ ) { mainActivity.getProdKey.launch(arrayOf("*/*")) },
+ HomeSetting(
+ R.string.install_firmware,
+ R.string.install_firmware_description,
+ R.drawable.ic_firmware
+ ) { mainActivity.getFirmware.launch(arrayOf("application/zip")) },
+ HomeSetting(
+ R.string.share_log,
+ R.string.share_log_description,
+ R.drawable.ic_log
+ ) { shareLog() },
+ HomeSetting(
+ R.string.about,
+ R.string.about_description,
+ R.drawable.ic_info_outline
+ ) {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment)
+ }
+ )
+
+ if (!BuildConfig.PREMIUM) {
+ optionsList.add(
+ 0,
+ HomeSetting(
+ R.string.get_early_access,
+ R.string.get_early_access_description,
+ R.drawable.ic_diamond
+ ) {
+ exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ parentFragmentManager.primaryNavigationFragment?.findNavController()
+ ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment)
+ }
+ )
+ }
+
+ binding.homeSettingsList.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList)
+ }
+
+ setInsets()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ exitTransition = null
+ homeViewModel.setNavigationVisibility(visible = true, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = true)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun openFileManager() {
+ // First, try to open the user data folder directly
+ try {
+ startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW))
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ try {
+ startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE"))
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ // Just try to open the file manager, try the package name used on "normal" phones
+ try {
+ startActivity(getFileManagerIntent("com.google.android.documentsui"))
+ showNoLinkNotification()
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ try {
+ // Next, try the AOSP package name
+ startActivity(getFileManagerIntent("com.android.documentsui"))
+ showNoLinkNotification()
+ return
+ } catch (_: ActivityNotFoundException) {
+ }
+
+ Toast.makeText(
+ requireContext(),
+ resources.getString(R.string.no_file_manager),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ private fun getFileManagerIntent(packageName: String): Intent {
+ // Fragile, but some phones don't expose the system file manager in any better way
+ val intent = Intent(Intent.ACTION_MAIN)
+ intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity")
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ return intent
+ }
+
+ private fun getFileManagerIntentOnDocumentProvider(action: String): Intent {
+ val authority = "${requireContext().packageName}.user"
+ val intent = Intent(action)
+ intent.addCategory(Intent.CATEGORY_DEFAULT)
+ intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID)
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ return intent
+ }
+
+ private fun showNoLinkNotification() {
+ val builder = NotificationCompat.Builder(
+ requireContext(),
+ getString(R.string.notice_notification_channel_id)
+ )
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.notification_no_directory_link))
+ .setContentText(getString(R.string.notification_no_directory_link_description))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setAutoCancel(true)
+ // TODO: Make the click action for this notification lead to a help article
+
+ with(NotificationManagerCompat.from(requireContext())) {
+ if (ActivityCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.POST_NOTIFICATIONS
+ ) != PackageManager.PERMISSION_GRANTED
+ ) {
+ Toast.makeText(
+ requireContext(),
+ resources.getString(R.string.notification_permission_not_granted),
+ Toast.LENGTH_LONG
+ ).show()
+ return
+ }
+ notify(0, builder.build())
+ }
+ }
+
+ private fun driverInstaller() {
+ // Get the driver name for the dialog message.
+ var driverName = GpuDriverHelper.customDriverName
+ if (driverName == null) {
+ driverName = getString(R.string.system_gpu_driver)
+ }
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(getString(R.string.select_gpu_driver_title))
+ .setMessage(driverName)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
+ GpuDriverHelper.installDefaultDriver(requireContext())
+ Toast.makeText(
+ requireContext(),
+ R.string.select_gpu_driver_use_default,
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
+ mainActivity.getDriver.launch(arrayOf("application/zip"))
+ }
+ .show()
+ }
+
+ private fun shareLog() {
+ val file = DocumentFile.fromSingleUri(
+ mainActivity,
+ DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt"
+ )
+ )!!
+ if (file.exists()) {
+ val intent = Intent(Intent.ACTION_SEND)
+ .setDataAndType(file.uri, FileUtil.TEXT_PLAIN)
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(Intent.EXTRA_STREAM, file.uri)
+ startActivity(Intent.createChooser(intent, getText(R.string.share_log)))
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getText(R.string.share_log_missing),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ binding.scrollViewSettings.updatePadding(
+ top = barInsets.top,
+ bottom = barInsets.bottom
+ )
+
+ val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams
+ mlpScrollSettings.leftMargin = leftInsets
+ mlpScrollSettings.rightMargin = rightInsets
+ binding.scrollViewSettings.layoutParams = mlpScrollSettings
+
+ binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation)
+
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail)
+ } else {
+ binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail)
+ }
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
new file mode 100644
index 000000000..36e63bb9e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ImportExportSavesFragment.kt
@@ -0,0 +1,210 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.widget.Toast
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.DocumentProvider
+import org.yuzu.yuzu_emu.getPublicFilesDir
+import org.yuzu.yuzu_emu.utils.FileUtil
+import java.io.BufferedOutputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.FilenameFilter
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+class ImportExportSavesFragment : DialogFragment() {
+ private val context = YuzuApplication.appContext
+ private val savesFolder =
+ "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
+
+ // Get first subfolder in saves folder (should be the user folder)
+ private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
+ private var lastZipCreated: File? = null
+
+ private lateinit var startForResultExportSave: ActivityResultLauncher
+ private lateinit var documentPicker: ActivityResultLauncher>
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val activity = requireActivity() as AppCompatActivity
+
+ val activityResultRegistry = requireActivity().activityResultRegistry
+ startForResultExportSave = activityResultRegistry.register(
+ "startForResultExportSaveKey",
+ ActivityResultContracts.StartActivityForResult()
+ ) {
+ File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
+ }
+ documentPicker = activityResultRegistry.register(
+ "documentPickerKey",
+ ActivityResultContracts.OpenDocument()
+ ) {
+ it?.let { uri -> importSave(uri, activity) }
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return if (savesFolderRoot == "") {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.manage_save_data)
+ .setMessage(R.string.import_export_saves_no_profile)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ } else {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.manage_save_data)
+ .setMessage(R.string.manage_save_data_description)
+ .setNegativeButton(R.string.export_saves) { _, _ ->
+ exportSave()
+ }
+ .setPositiveButton(R.string.import_saves) { _, _ ->
+ documentPicker.launch(arrayOf("application/zip"))
+ }
+ .setNeutralButton(android.R.string.cancel, null)
+ .show()
+ }
+ }
+
+ /**
+ * Zips the save files located in the given folder path and creates a new zip file with the current date and time.
+ * @return true if the zip file is successfully created, false otherwise.
+ */
+ private fun zipSave(): Boolean {
+ try {
+ val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
+ tempFolder.mkdirs()
+ val saveFolder = File(savesFolderRoot)
+ val outputZipFile = File(
+ tempFolder,
+ "yuzu saves - ${
+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
+ }.zip"
+ )
+ outputZipFile.createNewFile()
+ ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
+ saveFolder.walkTopDown().forEach { file ->
+ val zipFileName =
+ file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
+ if (zipFileName == "")
+ return@forEach
+ val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
+ zos.putNextEntry(entry)
+ if (file.isFile)
+ file.inputStream().use { fis -> fis.copyTo(zos) }
+ }
+ }
+ lastZipCreated = outputZipFile
+ } catch (e: Exception) {
+ return false
+ }
+ return true
+ }
+
+ /**
+ * Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
+ */
+ private fun exportSave() {
+ CoroutineScope(Dispatchers.IO).launch {
+ val wasZipCreated = zipSave()
+ val lastZipFile = lastZipCreated
+ if (!wasZipCreated || lastZipFile == null) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
+ }
+ return@launch
+ }
+
+ withContext(Dispatchers.Main) {
+ val file = DocumentFile.fromSingleUri(
+ context, DocumentsContract.buildDocumentUri(
+ DocumentProvider.AUTHORITY,
+ "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
+ )
+ )!!
+ val intent = Intent(Intent.ACTION_SEND)
+ .setDataAndType(file.uri, "application/zip")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .putExtra(Intent.EXTRA_STREAM, file.uri)
+ startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
+ }
+ }
+ }
+
+ /**
+ * Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
+ * @param zipUri The Uri of the zip file containing the save file(s) to import.
+ */
+ private fun importSave(zipUri: Uri, activity: AppCompatActivity) {
+ val inputZip = context.contentResolver.openInputStream(zipUri)
+ // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
+ var validZip = false
+ val savesFolder = File(savesFolderRoot)
+ val cacheSaveDir = File("${context.cacheDir.path}/saves/")
+ cacheSaveDir.mkdir()
+
+ if (inputZip == null) {
+ Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
+ .show()
+ return
+ }
+
+ val filterTitleId =
+ FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
+
+ try {
+ CoroutineScope(Dispatchers.IO).launch {
+ FileUtil.unzip(inputZip, cacheSaveDir)
+ cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
+ File(savesFolder, savePath).deleteRecursively()
+ File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
+ validZip = true
+ }
+
+ withContext(Dispatchers.Main) {
+ if (!validZip) {
+ MessageDialogFragment.newInstance(
+ R.string.save_file_invalid_zip_structure,
+ R.string.save_file_invalid_zip_structure_description
+ ).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
+ return@withContext
+ }
+ Toast.makeText(
+ context,
+ context.getString(R.string.save_file_imported_success),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ cacheSaveDir.deleteRecursively()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
+ .show()
+ }
+ }
+
+ companion object {
+ const val TAG = "ImportExportSavesFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
new file mode 100644
index 000000000..c7880d8cc
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/IndeterminateProgressDialogFragment.kt
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.model.TaskViewModel
+
+
+class IndeterminateProgressDialogFragment : DialogFragment() {
+ private val taskViewModel: TaskViewModel by activityViewModels()
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val titleId = requireArguments().getInt(TITLE)
+
+ val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
+ progressBinding.progressBar.isIndeterminate = true
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(titleId)
+ .setView(progressBinding.root)
+ .create()
+ dialog.setCanceledOnTouchOutside(false)
+
+ taskViewModel.isComplete.observe(this) { complete ->
+ if (complete) {
+ dialog.dismiss()
+ when (val result = taskViewModel.result.value) {
+ is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show()
+ is MessageDialogFragment -> result.show(
+ parentFragmentManager,
+ MessageDialogFragment.TAG
+ )
+ }
+ taskViewModel.clear()
+ }
+ }
+
+ if (taskViewModel.isRunning.value == false) {
+ taskViewModel.runTask()
+ }
+ return dialog
+ }
+
+ companion object {
+ const val TAG = "IndeterminateProgressDialogFragment"
+
+ private const val TITLE = "Title"
+
+ fun newInstance(
+ activity: AppCompatActivity,
+ titleId: Int,
+ task: () -> Any
+ ): IndeterminateProgressDialogFragment {
+ val dialog = IndeterminateProgressDialogFragment()
+ val args = Bundle()
+ ViewModelProvider(activity)[TaskViewModel::class.java].task = task
+ args.putInt(TITLE, titleId)
+ dialog.arguments = args
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt
new file mode 100644
index 000000000..78419191c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicenseBottomSheetDialogFragment.kt
@@ -0,0 +1,59 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import org.yuzu.yuzu_emu.databinding.DialogLicenseBinding
+import org.yuzu.yuzu_emu.model.License
+import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
+
+class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() {
+ private var _binding: DialogLicenseBinding? = null
+ private val binding get() = _binding!!
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = DialogLicenseBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ BottomSheetBehavior.from(view.parent as View).state =
+ BottomSheetBehavior.STATE_HALF_EXPANDED
+
+ val license = requireArguments().parcelable(LICENSE)!!
+
+ binding.apply {
+ textTitle.setText(license.titleId)
+ textLink.setText(license.linkId)
+ textCopyright.setText(license.copyrightId)
+ textLicense.setText(license.licenseId)
+ }
+ }
+
+ companion object {
+ const val TAG = "LicenseBottomSheetDialogFragment"
+
+ const val LICENSE = "License"
+
+ fun newInstance(
+ license: License
+ ): LicenseBottomSheetDialogFragment {
+ val dialog = LicenseBottomSheetDialogFragment()
+ val bundle = Bundle()
+ bundle.putParcelable(LICENSE, license)
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt
new file mode 100644
index 000000000..59141e823
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/LicensesFragment.kt
@@ -0,0 +1,137 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.transition.MaterialSharedAxis
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.LicenseAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentLicensesBinding
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.License
+
+class LicensesFragment : Fragment() {
+ private var _binding: FragmentLicensesBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
+ returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentLicensesBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = false, animated = true)
+ homeViewModel.setStatusBarShadeVisibility(visible = false)
+
+ binding.toolbarLicenses.setNavigationOnClickListener {
+ binding.root.findNavController().popBackStack()
+ }
+
+ val licenses = listOf(
+ License(
+ R.string.license_fidelityfx_fsr,
+ R.string.license_fidelityfx_fsr_description,
+ R.string.license_fidelityfx_fsr_link,
+ R.string.license_fidelityfx_fsr_copyright,
+ R.string.license_fidelityfx_fsr_text
+ ),
+ License(
+ R.string.license_cubeb,
+ R.string.license_cubeb_description,
+ R.string.license_cubeb_link,
+ R.string.license_cubeb_copyright,
+ R.string.license_cubeb_text
+ ),
+ License(
+ R.string.license_dynarmic,
+ R.string.license_dynarmic_description,
+ R.string.license_dynarmic_link,
+ R.string.license_dynarmic_copyright,
+ R.string.license_dynarmic_text
+ ),
+ License(
+ R.string.license_ffmpeg,
+ R.string.license_ffmpeg_description,
+ R.string.license_ffmpeg_link,
+ R.string.license_ffmpeg_copyright,
+ R.string.license_ffmpeg_text
+ ),
+ License(
+ R.string.license_opus,
+ R.string.license_opus_description,
+ R.string.license_opus_link,
+ R.string.license_opus_copyright,
+ R.string.license_opus_text
+ ),
+ License(
+ R.string.license_sirit,
+ R.string.license_sirit_description,
+ R.string.license_sirit_link,
+ R.string.license_sirit_copyright,
+ R.string.license_sirit_text
+ ),
+ License(
+ R.string.license_adreno_tools,
+ R.string.license_adreno_tools_description,
+ R.string.license_adreno_tools_link,
+ R.string.license_adreno_tools_copyright,
+ R.string.license_adreno_tools_text
+ )
+ )
+
+ binding.listLicenses.apply {
+ layoutManager = LinearLayoutManager(requireContext())
+ adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses)
+ }
+
+ setInsets()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+
+ val mlpAppBar = binding.appbarLicenses.layoutParams as MarginLayoutParams
+ mlpAppBar.leftMargin = leftInsets
+ mlpAppBar.rightMargin = rightInsets
+ binding.appbarLicenses.layoutParams = mlpAppBar
+
+ val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams
+ mlpScrollAbout.leftMargin = leftInsets
+ mlpScrollAbout.rightMargin = rightInsets
+ binding.listLicenses.layoutParams = mlpScrollAbout
+
+ binding.listLicenses.updatePadding(bottom = barInsets.bottom)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
new file mode 100644
index 000000000..2db38fdc2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/MessageDialogFragment.kt
@@ -0,0 +1,62 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class MessageDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val titleId = requireArguments().getInt(TITLE)
+ val descriptionId = requireArguments().getInt(DESCRIPTION)
+ val helpLinkId = requireArguments().getInt(HELP_LINK)
+
+ val dialog = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.close, null)
+ .setTitle(titleId)
+ .setMessage(descriptionId)
+
+ if (helpLinkId != 0) {
+ dialog.setNeutralButton(R.string.learn_more) { _, _ ->
+ openLink(getString(helpLinkId))
+ }
+ }
+
+ return dialog.show()
+ }
+
+ private fun openLink(link: String) {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
+ startActivity(intent)
+ }
+
+ companion object {
+ const val TAG = "MessageDialogFragment"
+
+ private const val TITLE = "Title"
+ private const val DESCRIPTION = "Description"
+ private const val HELP_LINK = "Link"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int = 0
+ ): MessageDialogFragment {
+ val dialog = MessageDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE, titleId)
+ putInt(DESCRIPTION, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt
new file mode 100644
index 000000000..3478b9250
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/PermissionDeniedDialogFragment.kt
@@ -0,0 +1,38 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class PermissionDeniedDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ return MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int ->
+ openSettings()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .setTitle(R.string.permission_denied)
+ .setMessage(R.string.permission_denied_description)
+ .show()
+ }
+
+ private fun openSettings() {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ val uri = Uri.fromParts("package", requireActivity().packageName, null)
+ intent.data = uri
+ startActivity(intent)
+ }
+
+ companion object {
+ const val TAG = "PermissionDeniedDialogFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt
new file mode 100644
index 000000000..1b4b93ab8
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/ResetSettingsDialogFragment.kt
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+
+class ResetSettingsDialogFragment : DialogFragment() {
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val settingsActivity = requireActivity() as SettingsActivity
+
+ return MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.reset_all_settings)
+ .setMessage(R.string.reset_all_settings_description)
+ .setPositiveButton(android.R.string.ok) { _, _ ->
+ settingsActivity.onSettingsReset()
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ }
+
+ companion object {
+ const val TAG = "ResetSettingsDialogFragment"
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
new file mode 100644
index 000000000..ebc0f164a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
@@ -0,0 +1,236 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.inputmethod.InputMethodManager
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.core.widget.doOnTextChanged
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.preference.PreferenceManager
+import info.debatty.java.stringsimilarity.Jaccard
+import info.debatty.java.stringsimilarity.JaroWinkler
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.GameAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding
+import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
+import org.yuzu.yuzu_emu.model.Game
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.FileUtil
+import org.yuzu.yuzu_emu.utils.Log
+import java.util.Locale
+
+class SearchFragment : Fragment() {
+ private var _binding: FragmentSearchBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private lateinit var preferences: SharedPreferences
+
+ companion object {
+ private const val SEARCH_TEXT = "SearchText"
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSearchBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = false)
+ preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ if (savedInstanceState != null) {
+ binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
+ }
+
+ binding.gridGamesSearch.apply {
+ layoutManager = AutofitGridLayoutManager(
+ requireContext(),
+ requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
+
+ binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
+ if (text.toString().isNotEmpty()) {
+ binding.clearButton.visibility = View.VISIBLE
+ } else {
+ binding.clearButton.visibility = View.INVISIBLE
+ }
+ filterAndSearch()
+ }
+
+ gamesViewModel.apply {
+ searchFocused.observe(viewLifecycleOwner) { searchFocused ->
+ if (searchFocused) {
+ focusSearch()
+ gamesViewModel.setSearchFocused(false)
+ }
+ }
+
+ games.observe(viewLifecycleOwner) { filterAndSearch() }
+ searchedGames.observe(viewLifecycleOwner) {
+ (binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
+ if (it.isEmpty()) {
+ binding.noResultsView.visibility = View.VISIBLE
+ } else {
+ binding.noResultsView.visibility = View.GONE
+ }
+ }
+ }
+
+ binding.clearButton.setOnClickListener { binding.searchText.setText("") }
+
+ binding.searchBackground.setOnClickListener { focusSearch() }
+
+ setInsets()
+ filterAndSearch()
+ }
+
+ private inner class ScoredGame(val score: Double, val item: Game)
+
+ private fun filterAndSearch() {
+ val baseList = gamesViewModel.games.value!!
+ val filteredList: List = when (binding.chipGroup.checkedChipId) {
+ R.id.chip_recently_played -> {
+ baseList.filter {
+ val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
+ lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
+ }
+ }
+
+ R.id.chip_recently_added -> {
+ baseList.filter {
+ val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
+ addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
+ }
+ }
+
+ R.id.chip_homebrew -> {
+ baseList.filter {
+ Log.error("Guh - ${it.path}")
+ FileUtil.hasExtension(it.path, "nro")
+ || FileUtil.hasExtension(it.path, "nso")
+ }
+ }
+
+ R.id.chip_retail -> baseList.filter {
+ FileUtil.hasExtension(it.path, "xci")
+ || FileUtil.hasExtension(it.path, "nsp")
+ }
+
+ else -> baseList
+ }
+
+ if (binding.searchText.text.toString().isEmpty()
+ && binding.chipGroup.checkedChipId != View.NO_ID
+ ) {
+ gamesViewModel.setSearchedGames(filteredList)
+ return
+ }
+
+ val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
+ val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
+ val sortedList: List = filteredList.mapNotNull { game ->
+ val title = game.title.lowercase(Locale.getDefault())
+ val score = searchAlgorithm.similarity(searchTerm, title)
+ if (score > 0.03) {
+ ScoredGame(score, game)
+ } else {
+ null
+ }
+ }.sortedByDescending { it.score }.map { it.item }
+ gamesViewModel.setSearchedGames(sortedList)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ if (_binding != null) {
+ outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
+ }
+ }
+
+ private fun focusSearch() {
+ if (_binding != null) {
+ binding.searchText.requestFocus()
+ val imm =
+ requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
+ imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+ val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
+
+ binding.constraintSearch.updatePadding(
+ left = barInsets.left + cutoutInsets.left,
+ top = barInsets.top,
+ right = barInsets.right + cutoutInsets.right
+ )
+
+ binding.gridGamesSearch.updatePadding(
+ top = extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+ binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
+
+ val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.frameSearch.updatePadding(left = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
+ binding.noResultsView.updatePadding(left = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing + spacingNavigationRail,
+ right = chipSpacing
+ )
+ mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
+ mlpDivider.rightMargin = chipSpacing
+ } else {
+ binding.frameSearch.updatePadding(right = spacingNavigationRail)
+ binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
+ binding.noResultsView.updatePadding(right = spacingNavigationRail)
+ binding.chipGroup.updatePadding(
+ left = chipSpacing,
+ right = chipSpacing + spacingNavigationRail
+ )
+ mlpDivider.leftMargin = chipSpacing
+ mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
+ }
+ binding.divider.layoutParams = mlpDivider
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
new file mode 100644
index 000000000..258773380
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
@@ -0,0 +1,329 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.Manifest
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.isVisible
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.findNavController
+import androidx.preference.PreferenceManager
+import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
+import com.google.android.material.transition.MaterialFadeThrough
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.adapters.SetupAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.model.SetupPage
+import org.yuzu.yuzu_emu.ui.main.MainActivity
+import org.yuzu.yuzu_emu.utils.DirectoryInitialization
+import org.yuzu.yuzu_emu.utils.GameHelper
+import java.io.File
+
+class SetupFragment : Fragment() {
+ private var _binding: FragmentSetupBinding? = null
+ private val binding get() = _binding!!
+
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ private lateinit var mainActivity: MainActivity
+
+ private lateinit var hasBeenWarned: BooleanArray
+
+ companion object {
+ const val KEY_NEXT_VISIBILITY = "NextButtonVisibility"
+ const val KEY_BACK_VISIBILITY = "BackButtonVisibility"
+ const val KEY_HAS_BEEN_WARNED = "HasBeenWarned"
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ exitTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentSetupBinding.inflate(layoutInflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ mainActivity = requireActivity() as MainActivity
+
+ homeViewModel.setNavigationVisibility(visible = false, animated = false)
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ if (binding.viewPager2.currentItem > 0) {
+ pageBackward()
+ } else {
+ requireActivity().finish()
+ }
+ }
+ })
+
+ requireActivity().window.navigationBarColor =
+ ContextCompat.getColor(requireContext(), android.R.color.transparent)
+
+ val pages = mutableListOf()
+ pages.apply {
+ add(
+ SetupPage(
+ R.drawable.ic_yuzu_title,
+ R.string.welcome,
+ R.string.welcome_description,
+ 0,
+ true,
+ R.string.get_started,
+ { pageForward() },
+ false
+ )
+ )
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ add(
+ SetupPage(
+ R.drawable.ic_notification,
+ R.string.notifications,
+ R.string.notifications_description,
+ 0,
+ false,
+ R.string.give_permission,
+ { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
+ true,
+ R.string.notification_warning,
+ R.string.notification_warning_description,
+ 0,
+ {
+ NotificationManagerCompat.from(requireContext())
+ .areNotificationsEnabled()
+ }
+ )
+ )
+ }
+
+ add(
+ SetupPage(
+ R.drawable.ic_key,
+ R.string.keys,
+ R.string.keys_description,
+ R.drawable.ic_add,
+ true,
+ R.string.select_keys,
+ { mainActivity.getProdKey.launch(arrayOf("*/*")) },
+ true,
+ R.string.install_prod_keys_warning,
+ R.string.install_prod_keys_warning_description,
+ R.string.install_prod_keys_warning_help,
+ { File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_controller,
+ R.string.games,
+ R.string.games_description,
+ R.drawable.ic_add,
+ true,
+ R.string.add_games,
+ { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
+ true,
+ R.string.add_games_warning,
+ R.string.add_games_warning_description,
+ R.string.add_games_warning_help,
+ {
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()
+ }
+ )
+ )
+ add(
+ SetupPage(
+ R.drawable.ic_check,
+ R.string.done,
+ R.string.done_description,
+ R.drawable.ic_arrow_forward,
+ false,
+ R.string.text_continue,
+ { finishSetup() },
+ false
+ )
+ )
+ }
+
+ binding.viewPager2.apply {
+ adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
+ offscreenPageLimit = 2
+ isUserInputEnabled = false
+ }
+
+ binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() {
+ var previousPosition: Int = 0
+
+ override fun onPageSelected(position: Int) {
+ super.onPageSelected(position)
+
+ if (position == 1 && previousPosition == 0) {
+ showView(binding.buttonNext)
+ showView(binding.buttonBack)
+ } else if (position == 0 && previousPosition == 1) {
+ hideView(binding.buttonBack)
+ hideView(binding.buttonNext)
+ } else if (position == pages.size - 1 && previousPosition == pages.size - 2) {
+ hideView(binding.buttonNext)
+ } else if (position == pages.size - 2 && previousPosition == pages.size - 1) {
+ showView(binding.buttonNext)
+ }
+
+ previousPosition = position
+ }
+ })
+
+ binding.buttonNext.setOnClickListener {
+ val index = binding.viewPager2.currentItem
+ val currentPage = pages[index]
+
+ // Checks if the user has completed the task on the current page
+ if (currentPage.hasWarning) {
+ if (currentPage.taskCompleted.invoke()) {
+ pageForward()
+ return@setOnClickListener
+ }
+
+ if (!hasBeenWarned[index]) {
+ SetupWarningDialogFragment.newInstance(
+ currentPage.warningTitleId,
+ currentPage.warningDescriptionId,
+ currentPage.warningHelpLinkId,
+ index
+ ).show(childFragmentManager, SetupWarningDialogFragment.TAG)
+ return@setOnClickListener
+ }
+ }
+ pageForward()
+ }
+ binding.buttonBack.setOnClickListener { pageBackward() }
+
+ if (savedInstanceState != null) {
+ val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY)
+ val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY)
+ hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!!
+
+ if (nextIsVisible) {
+ binding.buttonNext.visibility = View.VISIBLE
+ }
+ if (backIsVisible) {
+ binding.buttonBack.visibility = View.VISIBLE
+ }
+ } else {
+ hasBeenWarned = BooleanArray(pages.size)
+ }
+
+ setInsets()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
+ outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
+ outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ private val permissionLauncher =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) {
+ if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
+ PermissionDeniedDialogFragment().show(
+ childFragmentManager,
+ PermissionDeniedDialogFragment.TAG
+ )
+ }
+ }
+
+ private fun finishSetup() {
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit()
+ .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
+ .apply()
+ mainActivity.finishSetup(binding.root.findNavController())
+ }
+
+ private fun showView(view: View) {
+ view.apply {
+ alpha = 0f
+ visibility = View.VISIBLE
+ isClickable = true
+ }.animate().apply {
+ duration = 300
+ alpha(1f)
+ }.start()
+ }
+
+ private fun hideView(view: View) {
+ if (view.visibility == View.INVISIBLE) {
+ return
+ }
+
+ view.apply {
+ alpha = 1f
+ isClickable = false
+ }.animate().apply {
+ duration = 300
+ alpha(0f)
+ }.withEndAction {
+ view.visibility = View.INVISIBLE
+ }
+ }
+
+ fun pageForward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1
+ }
+
+ fun pageBackward() {
+ binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1
+ }
+
+ fun setPageWarned(page: Int) {
+ hasBeenWarned[page] = true
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ view.setPadding(
+ barInsets.left + cutoutInsets.left,
+ barInsets.top + cutoutInsets.top,
+ barInsets.right + cutoutInsets.right,
+ barInsets.bottom + cutoutInsets.bottom
+ )
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt
new file mode 100644
index 000000000..b2c1d54af
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupWarningDialogFragment.kt
@@ -0,0 +1,86 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.fragments
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.yuzu.yuzu_emu.R
+
+class SetupWarningDialogFragment : DialogFragment() {
+ private var titleId: Int = 0
+ private var descriptionId: Int = 0
+ private var helpLinkId: Int = 0
+ private var page: Int = 0
+
+ private lateinit var setupFragment: SetupFragment
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ titleId = requireArguments().getInt(TITLE)
+ descriptionId = requireArguments().getInt(DESCRIPTION)
+ helpLinkId = requireArguments().getInt(HELP_LINK)
+ page = requireArguments().getInt(PAGE)
+
+ setupFragment = requireParentFragment() as SetupFragment
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
+ setupFragment.pageForward()
+ setupFragment.setPageWarned(page)
+ }
+ .setNegativeButton(R.string.warning_cancel, null)
+
+ if (titleId != 0) {
+ builder.setTitle(titleId)
+ } else {
+ builder.setTitle("")
+ }
+ if (descriptionId != 0) {
+ builder.setMessage(descriptionId)
+ }
+ if (helpLinkId != 0) {
+ builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
+ val helpLink = resources.getString(R.string.install_prod_keys_warning_help)
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
+ startActivity(intent)
+ }
+ }
+
+ return builder.show()
+ }
+
+ companion object {
+ const val TAG = "SetupWarningDialogFragment"
+
+ private const val TITLE = "Title"
+ private const val DESCRIPTION = "Description"
+ private const val HELP_LINK = "HelpLink"
+ private const val PAGE = "Page"
+
+ fun newInstance(
+ titleId: Int,
+ descriptionId: Int,
+ helpLinkId: Int,
+ page: Int
+ ): SetupWarningDialogFragment {
+ val dialog = SetupWarningDialogFragment()
+ val bundle = Bundle()
+ bundle.apply {
+ putInt(TITLE, titleId)
+ putInt(DESCRIPTION, descriptionId)
+ putInt(HELP_LINK, helpLinkId)
+ putInt(PAGE, page)
+ }
+ dialog.arguments = bundle
+ return dialog
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt
new file mode 100644
index 000000000..be5e4c86c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/layout/AutofitGridLayoutManager.kt
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.layout
+
+import android.content.Context
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.Recycler
+import org.yuzu.yuzu_emu.R
+
+/**
+ * Cut down version of the solution provided here
+ * https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
+ */
+class AutofitGridLayoutManager(
+ context: Context,
+ columnWidth: Int
+) : GridLayoutManager(context, 1) {
+ private var columnWidth = 0
+ private var isColumnWidthChanged = true
+ private var lastWidth = 0
+ private var lastHeight = 0
+
+ init {
+ setColumnWidth(checkedColumnWidth(context, columnWidth))
+ }
+
+ private fun checkedColumnWidth(context: Context, columnWidth: Int): Int {
+ var newColumnWidth = columnWidth
+ if (newColumnWidth <= 0) {
+ newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
+ }
+ return newColumnWidth
+ }
+
+ private fun setColumnWidth(newColumnWidth: Int) {
+ if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
+ columnWidth = newColumnWidth
+ isColumnWidthChanged = true
+ }
+ }
+
+ override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) {
+ val width = width
+ val height = height
+ if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
+ val totalSpace: Int = if (orientation == VERTICAL) {
+ width - paddingRight - paddingLeft
+ } else {
+ height - paddingTop - paddingBottom
+ }
+ val spanCount = 1.coerceAtLeast(totalSpace / columnWidth)
+ setSpanCount(spanCount)
+ isColumnWidthChanged = false
+ }
+ lastWidth = width
+ lastHeight = height
+ super.onLayoutChildren(recycler, state)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
new file mode 100644
index 000000000..2a17653b2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+import java.util.HashSet
+
+@Parcelize
+@Serializable
+class Game(
+ val title: String,
+ val description: String,
+ val regions: String,
+ val path: String,
+ val gameId: String,
+ val company: String
+) : Parcelable {
+ val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
+ val keyLastPlayedTime get() = "${gameId}_LastPlayed"
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is Game)
+ return false
+
+ return title == other.title
+ && description == other.description
+ && regions == other.regions
+ && path == other.path
+ && gameId == other.gameId
+ && company == other.company
+ }
+
+ companion object {
+ val extensions: Set = HashSet(
+ listOf(".xci", ".nsp", ".nca", ".nro")
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
new file mode 100644
index 000000000..7059856f1
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.utils.GameHelper
+import java.util.Locale
+
+class GamesViewModel : ViewModel() {
+ private val _games = MutableLiveData>(emptyList())
+ val games: LiveData> get() = _games
+
+ private val _searchedGames = MutableLiveData>(emptyList())
+ val searchedGames: LiveData> get() = _searchedGames
+
+ private val _isReloading = MutableLiveData(false)
+ val isReloading: LiveData get() = _isReloading
+
+ private val _shouldSwapData = MutableLiveData(false)
+ val shouldSwapData: LiveData get() = _shouldSwapData
+
+ private val _shouldScrollToTop = MutableLiveData(false)
+ val shouldScrollToTop: LiveData get() = _shouldScrollToTop
+
+ private val _searchFocused = MutableLiveData(false)
+ val searchFocused: LiveData get() = _searchFocused
+
+ init {
+ // Ensure keys are loaded so that ROM metadata can be decrypted.
+ NativeLibrary.reloadKeys()
+
+ // Retrieve list of cached games
+ val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ .getStringSet(GameHelper.KEY_GAMES, emptySet())
+ if (storedGames!!.isNotEmpty()) {
+ val deserializedGames = mutableSetOf()
+ storedGames.forEach {
+ val game: Game = Json.decodeFromString(it)
+ val gameExists =
+ DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path))
+ ?.exists()
+ if (gameExists == true) {
+ deserializedGames.add(game)
+ }
+ }
+ setGames(deserializedGames.toList())
+ }
+ reloadGames(false)
+ }
+
+ fun setGames(games: List) {
+ val sortedList = games.sortedWith(
+ compareBy(
+ { it.title.lowercase(Locale.getDefault()) },
+ { it.path }
+ )
+ )
+
+ _games.postValue(sortedList)
+ }
+
+ fun setSearchedGames(games: List) {
+ _searchedGames.postValue(games)
+ }
+
+ fun setShouldSwapData(shouldSwap: Boolean) {
+ _shouldSwapData.postValue(shouldSwap)
+ }
+
+ fun setShouldScrollToTop(shouldScroll: Boolean) {
+ _shouldScrollToTop.postValue(shouldScroll)
+ }
+
+ fun setSearchFocused(searchFocused: Boolean) {
+ _searchFocused.postValue(searchFocused)
+ }
+
+ fun reloadGames(directoryChanged: Boolean) {
+ if (isReloading.value == true)
+ return
+ _isReloading.postValue(true)
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ NativeLibrary.resetRomMetadata()
+ setGames(GameHelper.getGames())
+ _isReloading.postValue(false)
+
+ if (directoryChanged) {
+ setShouldSwapData(true)
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
new file mode 100644
index 000000000..7049f2fa5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class HomeSetting(
+ val titleId: Int,
+ val descriptionId: Int,
+ val iconId: Int,
+ val onClick: () -> Unit
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
new file mode 100644
index 000000000..263ee7144
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+
+class HomeViewModel : ViewModel() {
+ private val _navigationVisible = MutableLiveData>()
+ val navigationVisible: LiveData> get() = _navigationVisible
+
+ private val _statusBarShadeVisible = MutableLiveData(true)
+ val statusBarShadeVisible: LiveData get() = _statusBarShadeVisible
+
+ var navigatedToSetup = false
+
+ init {
+ _navigationVisible.value = Pair(false, false)
+ }
+
+ fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
+ if (_navigationVisible.value?.first == visible) {
+ return
+ }
+ _navigationVisible.value = Pair(visible, animated)
+ }
+
+ fun setStatusBarShadeVisibility(visible: Boolean) {
+ if (_statusBarShadeVisible.value == visible) {
+ return
+ }
+ _statusBarShadeVisible.value = visible
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt
new file mode 100644
index 000000000..f24d5cf34
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/License.kt
@@ -0,0 +1,16 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class License(
+ val titleId: Int,
+ val descriptionId: Int,
+ val linkId: Int,
+ val copyrightId: Int,
+ val licenseId: Int
+) : Parcelable
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
new file mode 100644
index 000000000..b4b78e42d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import android.net.Uri
+import android.provider.DocumentsContract
+
+class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) {
+ val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
new file mode 100644
index 000000000..a0c878e1c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+data class SetupPage(
+ val iconId: Int,
+ val titleId: Int,
+ val descriptionId: Int,
+ val buttonIconId: Int,
+ val leftAlignedIcon: Boolean,
+ val buttonTextId: Int,
+ val buttonAction: () -> Unit,
+ val hasWarning: Boolean,
+ val warningTitleId: Int = 0,
+ val warningDescriptionId: Int = 0,
+ val warningHelpLinkId: Int = 0,
+ val taskCompleted: () -> Boolean = { true }
+)
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
new file mode 100644
index 000000000..27ea725a5
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/model/TaskViewModel.kt
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.model
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class TaskViewModel : ViewModel() {
+ private val _result = MutableLiveData()
+ val result: LiveData = _result
+
+ private val _isComplete = MutableLiveData()
+ val isComplete: LiveData = _isComplete
+
+ private val _isRunning = MutableLiveData()
+ val isRunning: LiveData = _isRunning
+
+ lateinit var task: () -> Any
+
+ init {
+ clear()
+ }
+
+ fun clear() {
+ _result.value = Any()
+ _isComplete.value = false
+ _isRunning.value = false
+ }
+
+ fun runTask() {
+ if (_isRunning.value == true) {
+ return
+ }
+ _isRunning.value = true
+
+ viewModelScope.launch(Dispatchers.IO) {
+ val res = task()
+ _result.postValue(res)
+ _isComplete.postValue(true)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
new file mode 100644
index 000000000..c9f5797ac
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
@@ -0,0 +1,1064 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.app.Activity
+import android.content.Context
+import android.content.SharedPreferences
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Point
+import android.graphics.Rect
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.VectorDrawable
+import android.os.Build
+import android.util.AttributeSet
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.SurfaceView
+import android.view.View
+import android.view.View.OnTouchListener
+import android.view.WindowInsets
+import androidx.core.content.ContextCompat
+import androidx.preference.PreferenceManager
+import androidx.window.layout.WindowMetricsCalculator
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonType
+import org.yuzu.yuzu_emu.NativeLibrary.StickType
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Draws the interactive input overlay on top of the
+ * [SurfaceView] that is rendering emulation.
+ */
+class InputOverlay(context: Context, attrs: AttributeSet?) : SurfaceView(context, attrs),
+ OnTouchListener {
+ private val overlayButtons: MutableSet = HashSet()
+ private val overlayDpads: MutableSet = HashSet()
+ private val overlayJoysticks: MutableSet = HashSet()
+
+ private var inEditMode = false
+ private var buttonBeingConfigured: InputOverlayDrawableButton? = null
+ private var dpadBeingConfigured: InputOverlayDrawableDpad? = null
+ private var joystickBeingConfigured: InputOverlayDrawableJoystick? = null
+
+ private lateinit var windowInsets: WindowInsets
+
+ 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 {
+ // 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,
+ 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,
+ 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,
+ resOuter: Int,
+ defaultResInner: Int,
+ pressedResInner: Int,
+ joystick: Int,
+ button: Int,
+ orientation: String
+ ): InputOverlayDrawableJoystick {
+ // Resources handle for fetching the initial Drawable resource.
+ val res = context.resources
+
+ // SharedPreference to retrieve the X and Y coordinates for the InputOverlayDrawableJoystick.
+ val sPrefs = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // Decide scale based on user preference
+ var scale = 0.3f
+ scale *= (sPrefs.getInt(Settings.PREF_CONTROL_SCALE, 50) + 50).toFloat()
+ scale /= 100f
+
+ // Initialize the InputOverlayDrawableJoystick.
+ val bitmapOuter = getBitmap(context, resOuter, scale)
+ val bitmapInnerDefault = getBitmap(context, defaultResInner, 1.0f)
+ val bitmapInnerPressed = getBitmap(context, pressedResInner, 1.0f)
+
+ // Get the minimum and maximum coordinates of the screen where the button can be placed.
+ val min = windowSize.first
+ val max = windowSize.second
+
+ // The X and Y coordinates of the InputOverlayDrawableButton on the InputOverlay.
+ // These were set in the input overlay configuration menu.
+ val drawableXPercent = sPrefs.getFloat("$button$orientation-X", 0f)
+ val drawableYPercent = sPrefs.getFloat("$button$orientation-Y", 0f)
+ val drawableX = (drawableXPercent * max.x + min.x).toInt()
+ val drawableY = (drawableYPercent * max.y + min.y).toInt()
+ val outerScale = 1.66f
+
+ // Now set the bounds for the InputOverlayDrawableJoystick.
+ // This will dictate where on the screen (and the what the size) the InputOverlayDrawableJoystick will be.
+ val outerSize = bitmapOuter.width
+ val outerRect = Rect(
+ drawableX - (outerSize / 2),
+ drawableY - (outerSize / 2),
+ drawableX + (outerSize / 2),
+ drawableY + (outerSize / 2)
+ )
+ val innerRect =
+ Rect(0, 0, (outerSize / outerScale).toInt(), (outerSize / outerScale).toInt())
+
+ // Send the drawableId to the joystick so it can be referenced when saving control position.
+ val overlayDrawable = InputOverlayDrawableJoystick(
+ res,
+ bitmapOuter,
+ bitmapInnerDefault,
+ bitmapInnerPressed,
+ outerRect,
+ innerRect,
+ joystick,
+ button
+ )
+
+ // Need to set the image's position
+ overlayDrawable.setPosition(drawableX, drawableY)
+ val savedOpacity = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100)
+ overlayDrawable.setOpacity(savedOpacity * 255 / 100)
+ return overlayDrawable
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt
new file mode 100644
index 000000000..4a93e0b14
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableButton.kt
@@ -0,0 +1,148 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param defaultStateBitmap [Bitmap] to use with the default state Drawable.
+ * @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable.
+ * @param buttonId Identifier for this type of button.
+ */
+class InputOverlayDrawableButton(
+ res: Resources,
+ defaultStateBitmap: Bitmap,
+ pressedStateBitmap: Bitmap,
+ val buttonId: Int
+) {
+ // The ID value what motion event is tracking
+ var trackId: Int
+
+ // The drawable position on the screen
+ private var buttonPositionX = 0
+ private var buttonPositionY = 0
+
+ val width: Int
+ val height: Int
+
+ private val defaultStateBitmap: BitmapDrawable
+ private val pressedStateBitmap: BitmapDrawable
+ private var pressedState = false
+
+ private var previousTouchX = 0
+ private var previousTouchY = 0
+ var controlPositionX = 0
+ var controlPositionY = 0
+
+ init {
+ this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
+ this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap)
+ trackId = -1
+ width = this.defaultStateBitmap.intrinsicWidth
+ height = this.defaultStateBitmap.intrinsicHeight
+ }
+
+ /**
+ * Updates button status based on the motion event.
+ *
+ * @return true if value was changed
+ */
+ fun updateStatus(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ pressedState = true
+ trackId = pointerId
+ return true
+ }
+
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ pressedState = false
+ trackId = -1
+ return true
+ }
+
+ return false
+ }
+
+ fun setPosition(x: Int, y: Int) {
+ buttonPositionX = x
+ buttonPositionY = y
+ }
+
+ fun draw(canvas: Canvas?) {
+ currentStateBitmapDrawable.draw(canvas!!)
+ }
+
+ private val currentStateBitmapDrawable: BitmapDrawable
+ get() = if (pressedState) pressedStateBitmap else defaultStateBitmap
+
+ fun onConfigureTouch(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ controlPositionX = fingerPositionX - (width / 2)
+ controlPositionY = fingerPositionY - (height / 2)
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ controlPositionX += fingerPositionX - previousTouchX
+ controlPositionY += fingerPositionY - previousTouchY
+ setBounds(
+ controlPositionX,
+ controlPositionY,
+ width + controlPositionX,
+ height + controlPositionY
+ )
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+ }
+ return true
+ }
+
+ fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
+ defaultStateBitmap.setBounds(left, top, right, bottom)
+ pressedStateBitmap.setBounds(left, top, right, bottom)
+ }
+
+ fun setOpacity(value: Int) {
+ defaultStateBitmap.alpha = value
+ pressedStateBitmap.alpha = value
+ }
+
+ val status: Int
+ get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED
+ val bounds: Rect
+ get() = defaultStateBitmap.bounds
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt
new file mode 100644
index 000000000..43d664d21
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableDpad.kt
@@ -0,0 +1,274 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param defaultStateBitmap [Bitmap] of the default state.
+ * @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction.
+ * @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction.
+ * @param buttonUp Identifier for the up button.
+ * @param buttonDown Identifier for the down button.
+ * @param buttonLeft Identifier for the left button.
+ * @param buttonRight Identifier for the right button.
+ */
+class InputOverlayDrawableDpad(
+ res: Resources,
+ defaultStateBitmap: Bitmap,
+ pressedOneDirectionStateBitmap: Bitmap,
+ pressedTwoDirectionsStateBitmap: Bitmap,
+ buttonUp: Int,
+ buttonDown: Int,
+ buttonLeft: Int,
+ buttonRight: Int
+) {
+ /**
+ * Gets one of the InputOverlayDrawableDpad's button IDs.
+ *
+ * @return the requested InputOverlayDrawableDpad's button ID.
+ */
+ // The ID identifying what type of button this Drawable represents.
+ val upId: Int
+ val downId: Int
+ val leftId: Int
+ val rightId: Int
+ var trackId: Int
+
+ val width: Int
+ val height: Int
+
+ private val defaultStateBitmap: BitmapDrawable
+ private val pressedOneDirectionStateBitmap: BitmapDrawable
+ private val pressedTwoDirectionsStateBitmap: BitmapDrawable
+
+ private var previousTouchX = 0
+ private var previousTouchY = 0
+ private var controlPositionX = 0
+ private var controlPositionY = 0
+
+ private var upButtonState = false
+ private var downButtonState = false
+ private var leftButtonState = false
+ private var rightButtonState = false
+
+ init {
+ this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
+ this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap)
+ this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap)
+ width = this.defaultStateBitmap.intrinsicWidth
+ height = this.defaultStateBitmap.intrinsicHeight
+ upId = buttonUp
+ downId = buttonDown
+ leftId = buttonLeft
+ rightId = buttonRight
+ trackId = -1
+ }
+
+ fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ trackId = pointerId
+ }
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ trackId = -1
+ upButtonState = false
+ downButtonState = false
+ leftButtonState = false
+ rightButtonState = false
+ return true
+ }
+ if (trackId == -1) {
+ return false
+ }
+ if (!dpad_slide && !isActionDown) {
+ return false
+ }
+ for (i in 0 until event.pointerCount) {
+ if (trackId != event.getPointerId(i)) {
+ continue
+ }
+
+ var touchX = event.getX(i)
+ var touchY = event.getY(i)
+ var maxY = bounds.bottom.toFloat()
+ var maxX = bounds.right.toFloat()
+ touchX -= bounds.centerX().toFloat()
+ maxX -= bounds.centerX().toFloat()
+ touchY -= bounds.centerY().toFloat()
+ maxY -= bounds.centerY().toFloat()
+ val axisX = touchX / maxX
+ val axisY = touchY / maxY
+ val oldUpState = upButtonState
+ val oldDownState = downButtonState
+ val oldLeftState = leftButtonState
+ val oldRightState = rightButtonState
+
+ upButtonState = axisY < -VIRT_AXIS_DEADZONE
+ downButtonState = axisY > VIRT_AXIS_DEADZONE
+ leftButtonState = axisX < -VIRT_AXIS_DEADZONE
+ rightButtonState = axisX > VIRT_AXIS_DEADZONE
+ return oldUpState != upButtonState || oldDownState != downButtonState || oldLeftState != leftButtonState || oldRightState != rightButtonState
+ }
+ return false
+ }
+
+ fun draw(canvas: Canvas) {
+ val px = controlPositionX + width / 2
+ val py = controlPositionY + height / 2
+
+ // Pressed up
+ if (upButtonState && !leftButtonState && !rightButtonState) {
+ pressedOneDirectionStateBitmap.draw(canvas)
+ return
+ }
+
+ // Pressed down
+ if (downButtonState && !leftButtonState && !rightButtonState) {
+ canvas.save()
+ canvas.rotate(180f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed left
+ if (leftButtonState && !upButtonState && !downButtonState) {
+ canvas.save()
+ canvas.rotate(270f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed right
+ if (rightButtonState && !upButtonState && !downButtonState) {
+ canvas.save()
+ canvas.rotate(90f, px.toFloat(), py.toFloat())
+ pressedOneDirectionStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed up left
+ if (upButtonState && leftButtonState && !rightButtonState) {
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ return
+ }
+
+ // Pressed up right
+ if (upButtonState && !leftButtonState && rightButtonState) {
+ canvas.save()
+ canvas.rotate(90f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed down right
+ if (downButtonState && !leftButtonState && rightButtonState) {
+ canvas.save()
+ canvas.rotate(180f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Pressed down left
+ if (downButtonState && leftButtonState && !rightButtonState) {
+ canvas.save()
+ canvas.rotate(270f, px.toFloat(), py.toFloat())
+ pressedTwoDirectionsStateBitmap.draw(canvas)
+ canvas.restore()
+ return
+ }
+
+ // Not pressed
+ defaultStateBitmap.draw(canvas)
+ }
+
+ val upStatus: Int
+ get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val downStatus: Int
+ get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val leftStatus: Int
+ get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+ val rightStatus: Int
+ get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED
+
+ fun onConfigureTouch(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ controlPositionX += fingerPositionX - previousTouchX
+ controlPositionY += fingerPositionY - previousTouchY
+ setBounds(
+ controlPositionX,
+ controlPositionY,
+ width + controlPositionX,
+ height + controlPositionY
+ )
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+ }
+ return true
+ }
+
+ fun setPosition(x: Int, y: Int) {
+ controlPositionX = x
+ controlPositionY = y
+ }
+
+ fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
+ defaultStateBitmap.setBounds(left, top, right, bottom)
+ pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom)
+ pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom)
+ }
+
+ fun setOpacity(value: Int) {
+ defaultStateBitmap.alpha = value
+ pressedOneDirectionStateBitmap.alpha = value
+ pressedTwoDirectionsStateBitmap.alpha = value
+ }
+
+ val bounds: Rect
+ get() = defaultStateBitmap.bounds
+
+ companion object {
+ const val VIRT_AXIS_DEADZONE = 0.5f
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt
new file mode 100644
index 000000000..f1d32192a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlayDrawableJoystick.kt
@@ -0,0 +1,282 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.overlay
+
+import android.content.res.Resources
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.drawable.BitmapDrawable
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Custom [BitmapDrawable] that is capable
+ * of storing it's own ID.
+ *
+ * @param res [Resources] instance.
+ * @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick.
+ * @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick.
+ * @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick.
+ * @param rectOuter [Rect] which represents the outer joystick bounds.
+ * @param rectInner [Rect] which represents the inner joystick bounds.
+ * @param joystickId The ID value what type of joystick this Drawable represents.
+ * @param buttonId The ID value what type of button this Drawable represents.
+ */
+class InputOverlayDrawableJoystick(
+ res: Resources,
+ bitmapOuter: Bitmap,
+ bitmapInnerDefault: Bitmap,
+ bitmapInnerPressed: Bitmap,
+ rectOuter: Rect,
+ rectInner: Rect,
+ val joystickId: Int,
+ val buttonId: Int
+) {
+ // The ID value what motion event is tracking
+ var trackId = -1
+
+ var xAxis = 0f
+ private var yAxis = 0f
+
+ val width: Int
+ val height: Int
+
+ private var opacity: Int = 0
+
+ private var virtBounds: Rect
+ private var origBounds: Rect
+
+ private val outerBitmap: BitmapDrawable
+ private val defaultStateInnerBitmap: BitmapDrawable
+ private val pressedStateInnerBitmap: BitmapDrawable
+
+ private var previousTouchX = 0
+ private var previousTouchY = 0
+ var controlPositionX = 0
+ var controlPositionY = 0
+
+ private val boundsBoxBitmap: BitmapDrawable
+
+ private var pressedState = false
+
+ // TODO: Add button support
+ val buttonStatus: Int
+ get() =
+ NativeLibrary.ButtonState.RELEASED
+ var bounds: Rect
+ get() = outerBitmap.bounds
+ set(bounds) {
+ outerBitmap.bounds = bounds
+ }
+
+ // Nintendo joysticks have y axis inverted
+ val realYAxis: Float
+ get() = -yAxis
+
+ private val currentStateBitmapDrawable: BitmapDrawable
+ get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap
+
+ init {
+ outerBitmap = BitmapDrawable(res, bitmapOuter)
+ defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault)
+ pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed)
+ boundsBoxBitmap = BitmapDrawable(res, bitmapOuter)
+ width = bitmapOuter.width
+ height = bitmapOuter.height
+ bounds = rectOuter
+ defaultStateInnerBitmap.bounds = rectInner
+ pressedStateInnerBitmap.bounds = rectInner
+ virtBounds = bounds
+ origBounds = outerBitmap.copyBounds()
+ boundsBoxBitmap.alpha = 0
+ boundsBoxBitmap.bounds = virtBounds
+ setInnerBounds()
+ }
+
+ fun draw(canvas: Canvas?) {
+ outerBitmap.draw(canvas!!)
+ currentStateBitmapDrawable.draw(canvas)
+ boundsBoxBitmap.draw(canvas)
+ }
+
+ fun updateStatus(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val xPosition = event.getX(pointerIndex).toInt()
+ val yPosition = event.getY(pointerIndex).toInt()
+ val pointerId = event.getPointerId(pointerIndex)
+ val motionEvent = event.action and MotionEvent.ACTION_MASK
+ val isActionDown =
+ motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
+ val isActionUp =
+ motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
+
+ if (isActionDown) {
+ if (!bounds.contains(xPosition, yPosition)) {
+ return false
+ }
+ pressedState = true
+ outerBitmap.alpha = 0
+ boundsBoxBitmap.alpha = opacity
+ if (EmulationMenuSettings.joystickRelCenter) {
+ virtBounds.offset(
+ xPosition - virtBounds.centerX(),
+ yPosition - virtBounds.centerY()
+ )
+ }
+ boundsBoxBitmap.bounds = virtBounds
+ trackId = pointerId
+ }
+
+ if (isActionUp) {
+ if (trackId != pointerId) {
+ return false
+ }
+ pressedState = false
+ xAxis = 0.0f
+ yAxis = 0.0f
+ outerBitmap.alpha = opacity
+ boundsBoxBitmap.alpha = 0
+ virtBounds = Rect(
+ origBounds.left,
+ origBounds.top,
+ origBounds.right,
+ origBounds.bottom
+ )
+ bounds = Rect(
+ origBounds.left,
+ origBounds.top,
+ origBounds.right,
+ origBounds.bottom
+ )
+ setInnerBounds()
+ trackId = -1
+ return true
+ }
+
+ if (trackId == -1) return false
+
+ for (i in 0 until event.pointerCount) {
+ if (trackId != event.getPointerId(i)) {
+ continue
+ }
+ var touchX = event.getX(i)
+ var touchY = event.getY(i)
+ var maxY = virtBounds.bottom.toFloat()
+ var maxX = virtBounds.right.toFloat()
+ touchX -= virtBounds.centerX().toFloat()
+ maxX -= virtBounds.centerX().toFloat()
+ touchY -= virtBounds.centerY().toFloat()
+ maxY -= virtBounds.centerY().toFloat()
+ val axisX = touchX / maxX
+ val axisY = touchY / maxY
+ val oldXAxis = xAxis
+ val oldYAxis = yAxis
+
+ // Clamp the circle pad input to a circle
+ val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat()
+ var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat()
+ if (radius > 1.0f) {
+ radius = 1.0f
+ }
+ xAxis = cos(angle.toDouble()).toFloat() * radius
+ yAxis = sin(angle.toDouble()).toFloat() * radius
+ setInnerBounds()
+ return oldXAxis != xAxis && oldYAxis != yAxis
+ }
+ return false
+ }
+
+ fun onConfigureTouch(event: MotionEvent): Boolean {
+ val pointerIndex = event.actionIndex
+ val fingerPositionX = event.getX(pointerIndex).toInt()
+ val fingerPositionY = event.getY(pointerIndex).toInt()
+
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ controlPositionX = fingerPositionX - (width / 2)
+ controlPositionY = fingerPositionY - (height / 2)
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ controlPositionX += fingerPositionX - previousTouchX
+ controlPositionY += fingerPositionY - previousTouchY
+ bounds = Rect(
+ controlPositionX,
+ controlPositionY,
+ outerBitmap.intrinsicWidth + controlPositionX,
+ outerBitmap.intrinsicHeight + controlPositionY
+ )
+ virtBounds = Rect(
+ controlPositionX,
+ controlPositionY,
+ outerBitmap.intrinsicWidth + controlPositionX,
+ outerBitmap.intrinsicHeight + controlPositionY
+ )
+ setInnerBounds()
+ bounds = Rect(
+ Rect(
+ controlPositionX,
+ controlPositionY,
+ outerBitmap.intrinsicWidth + controlPositionX,
+ outerBitmap.intrinsicHeight + controlPositionY
+ )
+ )
+ previousTouchX = fingerPositionX
+ previousTouchY = fingerPositionY
+ }
+ }
+ origBounds = outerBitmap.copyBounds()
+ return true
+ }
+
+ private fun setInnerBounds() {
+ var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt()
+ var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt()
+ if (x > virtBounds.centerX() + virtBounds.width() / 2) x =
+ virtBounds.centerX() + virtBounds.width() / 2
+ if (x < virtBounds.centerX() - virtBounds.width() / 2) x =
+ virtBounds.centerX() - virtBounds.width() / 2
+ if (y > virtBounds.centerY() + virtBounds.height() / 2) y =
+ virtBounds.centerY() + virtBounds.height() / 2
+ if (y < virtBounds.centerY() - virtBounds.height() / 2) y =
+ virtBounds.centerY() - virtBounds.height() / 2
+ val width = pressedStateInnerBitmap.bounds.width() / 2
+ val height = pressedStateInnerBitmap.bounds.height() / 2
+ defaultStateInnerBitmap.setBounds(
+ x - width,
+ y - height,
+ x + width,
+ y + height
+ )
+ pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds
+ }
+
+ fun setPosition(x: Int, y: Int) {
+ controlPositionX = x
+ controlPositionY = y
+ }
+
+ fun setOpacity(value: Int) {
+ opacity = value
+
+ defaultStateInnerBitmap.alpha = value
+ pressedStateInnerBitmap.alpha = value
+
+ if (trackId == -1) {
+ outerBitmap.alpha = value
+ boundsBoxBitmap.alpha = 0
+ } else {
+ outerBitmap.alpha = 0
+ boundsBoxBitmap.alpha = value
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
new file mode 100644
index 000000000..97eef40d2
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.transition.MaterialFadeThrough
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.adapters.GameAdapter
+import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding
+import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+
+class GamesFragment : Fragment() {
+ private var _binding: FragmentGamesBinding? = null
+ private val binding get() = _binding!!
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+ private val homeViewModel: HomeViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGamesBinding.inflate(inflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ homeViewModel.setNavigationVisibility(visible = true, animated = false)
+
+ binding.gridGames.apply {
+ layoutManager = AutofitGridLayoutManager(
+ requireContext(),
+ requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.swipeRefresh.apply {
+ // Add swipe down to refresh gesture
+ setOnRefreshListener {
+ gamesViewModel.reloadGames(false)
+ }
+
+ // Set theme color to the refresh animation's background
+ setProgressBackgroundColorSchemeColor(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorPrimary
+ )
+ )
+ setColorSchemeColors(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorOnPrimary
+ )
+ )
+
+ // Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
+ post {
+ if (_binding == null) {
+ return@post
+ }
+ binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
+ }
+ }
+
+ gamesViewModel.apply {
+ // Watch for when we get updates to any of our games lists
+ isReloading.observe(viewLifecycleOwner) { isReloading ->
+ binding.swipeRefresh.isRefreshing = isReloading
+ }
+ games.observe(viewLifecycleOwner) {
+ (binding.gridGames.adapter as GameAdapter).submitList(it)
+ if (it.isEmpty()) {
+ binding.noticeText.visibility = View.VISIBLE
+ } else {
+ binding.noticeText.visibility = View.GONE
+ }
+ }
+ shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
+ if (shouldSwapData) {
+ (binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!)
+ gamesViewModel.setShouldSwapData(false)
+ }
+ }
+
+ // Check if the user reselected the games menu item and then scroll to top of the list
+ shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll ->
+ if (shouldScroll) {
+ scrollToTop()
+ gamesViewModel.setShouldScrollToTop(false)
+ }
+ }
+ }
+
+ setInsets()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun scrollToTop() {
+ if (_binding != null) {
+ binding.gridGames.smoothScrollToPosition(0)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ binding.gridGames.updatePadding(
+ top = barInsets.top + extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+
+ binding.swipeRefresh.setProgressViewEndTarget(
+ false,
+ barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
+ )
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+ val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams
+ if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
+ mlpSwipe.rightMargin = rightInsets
+ } else {
+ mlpSwipe.leftMargin = leftInsets
+ mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
+ }
+ binding.swipeRefresh.layoutParams = mlpSwipe
+
+ binding.noticeText.updatePadding(bottom = spacingNavigation)
+
+ windowInsets
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
new file mode 100644
index 000000000..124f62f08
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
@@ -0,0 +1,470 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui.main
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+import android.view.WindowManager
+import android.view.animation.PathInterpolator
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.lifecycleScope
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.ui.setupWithNavController
+import androidx.preference.PreferenceManager
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.navigation.NavigationBarView
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
+import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
+import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
+import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
+import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
+import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
+import org.yuzu.yuzu_emu.model.GamesViewModel
+import org.yuzu.yuzu_emu.model.HomeViewModel
+import org.yuzu.yuzu_emu.utils.*
+import java.io.File
+import java.io.FilenameFilter
+import java.io.IOException
+
+class MainActivity : AppCompatActivity(), ThemeProvider {
+ private lateinit var binding: ActivityMainBinding
+
+ private val homeViewModel: HomeViewModel by viewModels()
+ private val gamesViewModel: GamesViewModel by viewModels()
+ private val settingsViewModel: SettingsViewModel by viewModels()
+
+ override var themeId: Int = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ val splashScreen = installSplashScreen()
+ splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
+
+ settingsViewModel.settings.loadSettings()
+
+ ThemeHelper.setTheme(this)
+
+ super.onCreate(savedInstanceState)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+
+ window.statusBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+ window.navigationBarColor =
+ ContextCompat.getColor(applicationContext, android.R.color.transparent)
+
+ binding.statusBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
+ binding.navigationBarShade.setBackgroundColor(
+ ThemeHelper.getColorWithOpacity(
+ MaterialColors.getColor(
+ binding.root,
+ com.google.android.material.R.attr.colorSurface
+ ),
+ ThemeHelper.SYSTEM_BAR_ALPHA
+ )
+ )
+ }
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+ setUpNavigation(navHostFragment.navController)
+ (binding.navigationView as NavigationBarView).setOnItemReselectedListener {
+ when (it.itemId) {
+ R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
+ R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
+ R.id.homeSettingsFragment -> SettingsActivity.launch(
+ this,
+ SettingsFile.FILE_NAME_CONFIG,
+ ""
+ )
+ }
+ }
+
+ // Prevents navigation from being drawn for a short time on recreation if set to hidden
+ if (!homeViewModel.navigationVisible.value?.first!!) {
+ binding.navigationView.visibility = View.INVISIBLE
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+
+ homeViewModel.navigationVisible.observe(this) {
+ showNavigation(it.first, it.second)
+ }
+ homeViewModel.statusBarShadeVisible.observe(this) { visible ->
+ showStatusBarShade(visible)
+ }
+
+ // Dismiss previous notifications (should not happen unless a crash occurred)
+ EmulationActivity.stopForegroundService(this)
+
+ setInsets()
+ }
+
+ fun finishSetup(navController: NavController) {
+ navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ showNavigation(visible = true, animated = true)
+ }
+
+ private fun setUpNavigation(navController: NavController) {
+ val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ .getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
+
+ if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
+ navController.navigate(R.id.firstTimeSetupFragment)
+ homeViewModel.navigatedToSetup = true
+ } else {
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ }
+ }
+
+ private fun showNavigation(visible: Boolean, animated: Boolean) {
+ if (!animated) {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ } else {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ return
+ }
+
+ val smallLayout = resources.getBoolean(R.bool.small_layout)
+ binding.navigationView.animate().apply {
+ if (visible) {
+ binding.navigationView.visibility = View.VISIBLE
+ duration = 300
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+
+ if (smallLayout) {
+ binding.navigationView.translationY =
+ binding.navigationView.height.toFloat() * 2
+ translationY(0f)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * -2
+ translationX(0f)
+ } else {
+ binding.navigationView.translationX =
+ binding.navigationView.width.toFloat() * 2
+ translationX(0f)
+ }
+ }
+ } else {
+ duration = 300
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+
+ if (smallLayout) {
+ translationY(binding.navigationView.height.toFloat() * 2)
+ } else {
+ if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
+ translationX(binding.navigationView.width.toFloat() * -2)
+ } else {
+ translationX(binding.navigationView.width.toFloat() * 2)
+ }
+ }
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.navigationView.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ private fun showStatusBarShade(visible: Boolean) {
+ binding.statusBarShade.animate().apply {
+ if (visible) {
+ binding.statusBarShade.visibility = View.VISIBLE
+ binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
+ duration = 300
+ translationY(0f)
+ interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
+ } else {
+ duration = 300
+ translationY(binding.navigationView.height.toFloat() * -2)
+ interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
+ }
+ }.withEndAction {
+ if (!visible) {
+ binding.statusBarShade.visibility = View.INVISIBLE
+ }
+ }.start()
+ }
+
+ override fun onResume() {
+ ThemeHelper.setCorrectTheme(this)
+ super.onResume()
+ }
+
+ override fun onDestroy() {
+ EmulationActivity.stopForegroundService(this)
+ super.onDestroy()
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
+ val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
+ mlpStatusShade.height = insets.top
+ binding.statusBarShade.layoutParams = mlpStatusShade
+
+ // The only situation where we care to have a nav bar shade is when it's at the bottom
+ // of the screen where scrolling list elements can go behind it.
+ val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
+ mlpNavShade.height = insets.bottom
+ binding.navigationBarShade.layoutParams = mlpNavShade
+
+ windowInsets
+ }
+
+ override fun setTheme(resId: Int) {
+ super.setTheme(resId)
+ themeId = resId
+ }
+
+ val getGamesDirectory =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ // When a new directory is picked, we currently will reset the existing games
+ // database. This effectively means that only one game directory is supported.
+ PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
+ .putString(GameHelper.KEY_GAME_PATH, result.toString())
+ .apply()
+
+ Toast.makeText(
+ applicationContext,
+ R.string.games_dir_selected,
+ Toast.LENGTH_LONG
+ ).show()
+
+ gamesViewModel.reloadGames(true)
+ }
+
+ val getProdKey =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ if (!FileUtil.hasExtension(result.toString(), "keys")) {
+ MessageDialogFragment.newInstance(
+ R.string.reading_keys_failure,
+ R.string.install_keys_failure_extension_description
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ return@registerForActivityResult
+ }
+
+ contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ val dstPath = DirectoryInitialization.userDirectory + "/keys/"
+ if (FileUtil.copyUriToInternalStorage(
+ applicationContext,
+ result,
+ dstPath,
+ "prod.keys"
+ )
+ ) {
+ if (NativeLibrary.reloadKeys()) {
+ Toast.makeText(
+ applicationContext,
+ R.string.install_keys_success,
+ Toast.LENGTH_SHORT
+ ).show()
+ gamesViewModel.reloadGames(true)
+ } else {
+ MessageDialogFragment.newInstance(
+ R.string.invalid_keys_error,
+ R.string.install_keys_failure_description,
+ R.string.dumping_keys_quickstart_link
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+ }
+
+ val getFirmware =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ val inputZip = contentResolver.openInputStream(result)
+ if (inputZip == null) {
+ Toast.makeText(
+ applicationContext,
+ getString(R.string.fatal_error),
+ Toast.LENGTH_LONG
+ ).show()
+ return@registerForActivityResult
+ }
+
+ val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
+
+ val firmwarePath =
+ File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
+ val cacheFirmwareDir = File("${cacheDir.path}/registered/")
+
+ val task: () -> Any = {
+ var messageToShow: Any
+ try {
+ FileUtil.unzip(inputZip, cacheFirmwareDir)
+ val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
+ val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
+ messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
+ MessageDialogFragment.newInstance(
+ R.string.firmware_installed_failure,
+ R.string.firmware_installed_failure_description
+ )
+ } else {
+ firmwarePath.deleteRecursively()
+ cacheFirmwareDir.copyRecursively(firmwarePath, true)
+ getString(R.string.save_file_imported_success)
+ }
+ } catch (e: Exception) {
+ messageToShow = getString(R.string.fatal_error)
+ } finally {
+ cacheFirmwareDir.deleteRecursively()
+ }
+ messageToShow
+ }
+
+ IndeterminateProgressDialogFragment.newInstance(
+ this,
+ R.string.firmware_installing,
+ task
+ ).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
+ }
+
+ val getAmiiboKey =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ if (!FileUtil.hasExtension(result.toString(), "bin")) {
+ MessageDialogFragment.newInstance(
+ R.string.reading_keys_failure,
+ R.string.install_keys_failure_extension_description
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ return@registerForActivityResult
+ }
+
+ contentResolver.takePersistableUriPermission(
+ result,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ val dstPath = DirectoryInitialization.userDirectory + "/keys/"
+ if (FileUtil.copyUriToInternalStorage(
+ applicationContext,
+ result,
+ dstPath,
+ "key_retail.bin"
+ )
+ ) {
+ if (NativeLibrary.reloadKeys()) {
+ Toast.makeText(
+ applicationContext,
+ R.string.install_keys_success,
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ MessageDialogFragment.newInstance(
+ R.string.invalid_keys_error,
+ R.string.install_keys_failure_description,
+ R.string.dumping_keys_quickstart_link
+ ).show(supportFragmentManager, MessageDialogFragment.TAG)
+ }
+ }
+ }
+
+ val getDriver =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
+ if (result == null)
+ return@registerForActivityResult
+
+ val takeFlags =
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ contentResolver.takePersistableUriPermission(
+ result,
+ takeFlags
+ )
+
+ val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
+ progressBinding.progressBar.isIndeterminate = true
+ val installationDialog = MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.installing_driver)
+ .setView(progressBinding.root)
+ .show()
+
+ lifecycleScope.launch {
+ withContext(Dispatchers.IO) {
+ // Ignore file exceptions when a user selects an invalid zip
+ try {
+ GpuDriverHelper.installCustomDriver(applicationContext, result)
+ } catch (_: IOException) {
+ }
+
+ withContext(Dispatchers.Main) {
+ installationDialog.dismiss()
+
+ val driverName = GpuDriverHelper.customDriverName
+ if (driverName != null) {
+ Toast.makeText(
+ applicationContext,
+ getString(
+ R.string.select_gpu_driver_install_success,
+ driverName
+ ),
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ applicationContext,
+ R.string.select_gpu_driver_error,
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
new file mode 100644
index 000000000..511a6e4fa
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
@@ -0,0 +1,11 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.ui.main
+
+interface ThemeProvider {
+ /**
+ * Provides theme ID by overriding an activity's 'setTheme' method and returning that result
+ */
+ var themeId: Int
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
new file mode 100644
index 000000000..9cfda74ee
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+class BiMap {
+ private val forward: MutableMap = HashMap()
+ private val backward: MutableMap = HashMap()
+
+ @Synchronized
+ fun add(key: K, value: V) {
+ forward[key] = value
+ backward[value] = key
+ }
+
+ @Synchronized
+ fun getForward(key: K): V? {
+ return forward[key]
+ }
+
+ @Synchronized
+ fun getBackward(key: V): K? {
+ return backward[key]
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt
new file mode 100644
index 000000000..791cea904
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ControllerMappingHelper.kt
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+
+/**
+ * Some controllers have incorrect mappings. This class has special-case fixes for them.
+ */
+class ControllerMappingHelper {
+ /**
+ * Some controllers report extra button presses that can be ignored.
+ */
+ fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
+ return if (isDualShock4(inputDevice)) {
+ // The two analog triggers generate analog motion events as well as a keycode.
+ // We always prefer to use the analog values, so throw away the button press
+ keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
+ } else false
+ }
+
+ /**
+ * Scale an axis to be zero-centered with a proper range.
+ */
+ fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
+ if (isDualShock4(inputDevice)) {
+ // Android doesn't have correct mappings for this controller's triggers. It reports them
+ // as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
+ // Scale them to properly zero-centered with a range of [0.0, 1.0].
+ if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
+ return (value + 1) / 2.0f
+ }
+ } else if (isXboxOneWireless(inputDevice)) {
+ // Same as the DualShock 4, the mappings are missing.
+ if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
+ return (value + 1) / 2.0f
+ }
+ if (axis == MotionEvent.AXIS_GENERIC_1) {
+ // This axis is stuck at ~.5. Ignore it.
+ return 0.0f
+ }
+ } else if (isMogaPro2Hid(inputDevice)) {
+ // This controller has a broken axis that reports a constant value. Ignore it.
+ if (axis == MotionEvent.AXIS_GENERIC_1) {
+ return 0.0f
+ }
+ }
+ return value
+ }
+
+ // Sony DualShock 4 controller
+ private fun isDualShock4(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
+ }
+
+ // Microsoft Xbox One controller
+ private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
+ }
+
+ // Moga Pro 2 HID
+ private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
+ return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
new file mode 100644
index 000000000..36c479e6c
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DirectoryInitialization.kt
@@ -0,0 +1,37 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Context
+import org.yuzu.yuzu_emu.NativeLibrary
+import java.io.IOException
+
+object DirectoryInitialization {
+ private var userPath: String? = null
+
+ var areDirectoriesReady: Boolean = false
+
+ fun start(context: Context) {
+ if (!areDirectoriesReady) {
+ initializeInternalStorage(context)
+ NativeLibrary.initializeEmulation()
+ areDirectoriesReady = true
+ }
+ }
+
+ val userDirectory: String?
+ get() {
+ check(areDirectoriesReady) { "Directory initialization is not ready!" }
+ return userPath
+ }
+
+ private fun initializeInternalStorage(context: Context) {
+ try {
+ userPath = context.getExternalFilesDir(null)!!.canonicalPath
+ NativeLibrary.setAppDirectory(userPath!!)
+ } catch (e: IOException) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
new file mode 100644
index 000000000..cc8ea6b9d
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
@@ -0,0 +1,112 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.MinimalDocumentFile
+import java.io.File
+import java.util.*
+
+class DocumentsTree {
+ private var root: DocumentsNode? = null
+
+ fun setRoot(rootUri: Uri?) {
+ root = null
+ root = DocumentsNode()
+ root!!.uri = rootUri
+ root!!.isDirectory = true
+ }
+
+ fun openContentUri(filepath: String, openMode: String?): Int {
+ val node = resolvePath(filepath) ?: return -1
+ return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode)
+ }
+
+ fun getFileSize(filepath: String): Long {
+ val node = resolvePath(filepath)
+ return if (node == null || node.isDirectory) {
+ 0
+ } else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString())
+ }
+
+ fun exists(filepath: String): Boolean {
+ return resolvePath(filepath) != null
+ }
+
+ private fun resolvePath(filepath: String): DocumentsNode? {
+ val tokens = StringTokenizer(filepath, File.separator, false)
+ var iterator = root
+ while (tokens.hasMoreTokens()) {
+ val token = tokens.nextToken()
+ if (token.isEmpty()) continue
+ iterator = find(iterator, token)
+ if (iterator == null) return null
+ }
+ return iterator
+ }
+
+ private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? {
+ if (parent!!.isDirectory && !parent.loaded) {
+ structTree(parent)
+ }
+ return parent.children[filename]
+ }
+
+ /**
+ * Construct current level directory tree
+ * @param parent parent node of this level
+ */
+ private fun structTree(parent: DocumentsNode) {
+ val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!)
+ for (document in documents) {
+ val node = DocumentsNode(document)
+ node.parent = parent
+ parent.children[node.name] = node
+ }
+ parent.loaded = true
+ }
+
+ private class DocumentsNode {
+ var parent: DocumentsNode? = null
+ val children: MutableMap = HashMap()
+ var name: String? = null
+ var uri: Uri? = null
+ var loaded = false
+ var isDirectory = false
+
+ constructor()
+ constructor(document: MinimalDocumentFile) {
+ name = document.filename
+ uri = document.uri
+ isDirectory = document.isDirectory
+ loaded = !isDirectory
+ }
+
+ private constructor(document: DocumentFile, isCreateDir: Boolean) {
+ name = document.name
+ uri = document.uri
+ isDirectory = isCreateDir
+ loaded = true
+ }
+
+ private fun rename(name: String) {
+ if (parent == null) {
+ return
+ }
+ parent!!.children.remove(this.name)
+ this.name = name
+ parent!!.children[name] = this
+ }
+ }
+
+ companion object {
+ fun isNativePath(path: String): Boolean {
+ return if (path.isNotEmpty()) {
+ path[0] == '/'
+ } else false
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt
new file mode 100644
index 000000000..e1e7a59d7
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/EmulationMenuSettings.kt
@@ -0,0 +1,68 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+
+object EmulationMenuSettings {
+ private val preferences =
+ PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+
+ // These must match what is defined in src/core/settings.h
+ const val LayoutOption_Default = 0
+ const val LayoutOption_SingleScreen = 1
+ const val LayoutOption_LargeScreen = 2
+ const val LayoutOption_SideScreen = 3
+ const val LayoutOption_MobilePortrait = 4
+ const val LayoutOption_MobileLandscape = 5
+
+ var joystickRelCenter: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value)
+ .apply()
+ }
+ var dpadSlide: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value)
+ .apply()
+ }
+ var hapticFeedback: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value)
+ .apply()
+ }
+
+ var landscapeScreenLayout: Int
+ get() = preferences.getInt(
+ Settings.PREF_MENU_SETTINGS_LANDSCAPE,
+ LayoutOption_MobileLandscape
+ )
+ set(value) {
+ preferences.edit()
+ .putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value)
+ .apply()
+ }
+ var showFps: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value)
+ .apply()
+ }
+ var showOverlay: Boolean
+ get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true)
+ set(value) {
+ preferences.edit()
+ .putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value)
+ .apply()
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
new file mode 100644
index 000000000..593dad8d3
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
@@ -0,0 +1,330 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.provider.DocumentsContract
+import androidx.documentfile.provider.DocumentFile
+import org.yuzu.yuzu_emu.model.MinimalDocumentFile
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.net.URLDecoder
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+
+object FileUtil {
+ const val PATH_TREE = "tree"
+ const val DECODE_METHOD = "UTF-8"
+ const val APPLICATION_OCTET_STREAM = "application/octet-stream"
+ const val TEXT_PLAIN = "text/plain"
+
+ /**
+ * Create a file from directory with filename.
+ * @param context Application context
+ * @param directory parent path for file.
+ * @param filename file display name.
+ * @return boolean
+ */
+ fun createFile(context: Context?, directory: String?, filename: String): DocumentFile? {
+ var decodedFilename = filename
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
+ decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD)
+ var mimeType = APPLICATION_OCTET_STREAM
+ if (decodedFilename.endsWith(".txt")) {
+ mimeType = TEXT_PLAIN
+ }
+ val exists = parent.findFile(decodedFilename)
+ return exists ?: parent.createFile(mimeType, decodedFilename)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ }
+ return null
+ }
+
+ /**
+ * Create a directory from directory with filename.
+ * @param context Application context
+ * @param directory parent path for directory.
+ * @param directoryName directory display name.
+ * @return boolean
+ */
+ fun createDir(context: Context?, directory: String?, directoryName: String?): DocumentFile? {
+ var decodedDirectoryName = directoryName
+ try {
+ val directoryUri = Uri.parse(directory)
+ val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
+ decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD)
+ val isExist = parent.findFile(decodedDirectoryName)
+ return isExist ?: parent.createDirectory(decodedDirectoryName)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot create file, error: " + e.message)
+ }
+ return null
+ }
+
+ /**
+ * Open content uri and return file descriptor to JNI.
+ * @param context Application context
+ * @param path Native content uri path
+ * @param openMode will be one of "r", "r", "rw", "wa", "rwa"
+ * @return file descriptor
+ */
+ @JvmStatic
+ fun openContentUri(context: Context, path: String, openMode: String?): Int {
+ try {
+ val uri = Uri.parse(path)
+ val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!)
+ if (parcelFileDescriptor == null) {
+ Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
+ return -1
+ }
+ val fileDescriptor = parcelFileDescriptor.detachFd()
+ parcelFileDescriptor.close()
+ return fileDescriptor
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
+ }
+ return -1
+ }
+
+ /**
+ * Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
+ * This function will be faster than DoucmentFile.listFiles
+ * @param context Application context
+ * @param uri Directory uri.
+ * @return CheapDocument lists.
+ */
+ fun listFiles(context: Context, uri: Uri): Array {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE
+ )
+ var c: Cursor? = null
+ val results: MutableList = ArrayList()
+ try {
+ val docId: String = if (isRootTreeUri(uri)) {
+ DocumentsContract.getTreeDocumentId(uri)
+ } else {
+ DocumentsContract.getDocumentId(uri)
+ }
+ val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
+ c = resolver.query(childrenUri, columns, null, null, null)
+ while (c!!.moveToNext()) {
+ val documentId = c.getString(0)
+ val documentName = c.getString(1)
+ val documentMimeType = c.getString(2)
+ val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
+ val document = MinimalDocumentFile(documentName, documentMimeType, documentUri)
+ results.add(document)
+ }
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list file error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return results.toTypedArray()
+ }
+
+ /**
+ * Check whether given path exists.
+ * @param path Native content uri path
+ * @return bool
+ */
+ fun exists(context: Context, path: String?): Boolean {
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ c = context.contentResolver.query(mUri, columns, null, null, null)
+ return c!!.count > 0
+ } catch (e: Exception) {
+ Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return false
+ }
+
+ /**
+ * Check whether given path is a directory
+ * @param path content uri path
+ * @return bool
+ */
+ fun isDirectory(context: Context, path: String): Boolean {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_MIME_TYPE
+ )
+ var isDirectory = false
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ val mimeType = c.getString(0)
+ isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot list files, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return isDirectory
+ }
+
+ /**
+ * Get file display name from given path
+ * @param path content uri path
+ * @return String display name
+ */
+ fun getFilename(context: Context, path: String): String {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME
+ )
+ var filename = ""
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ filename = c.getString(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return filename
+ }
+
+ fun getFilesName(context: Context, path: String): Array {
+ val uri = Uri.parse(path)
+ val files: MutableList = ArrayList()
+ for (file in listFiles(context, uri)) {
+ files.add(file.filename)
+ }
+ return files.toTypedArray()
+ }
+
+ /**
+ * Get file size from given path.
+ * @param path content uri path
+ * @return long file size
+ */
+ @JvmStatic
+ fun getFileSize(context: Context, path: String): Long {
+ val resolver = context.contentResolver
+ val columns = arrayOf(
+ DocumentsContract.Document.COLUMN_SIZE
+ )
+ var size: Long = 0
+ var c: Cursor? = null
+ try {
+ val mUri = Uri.parse(path)
+ c = resolver.query(mUri, columns, null, null, null)
+ c!!.moveToNext()
+ size = c.getLong(0)
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
+ } finally {
+ closeQuietly(c)
+ }
+ return size
+ }
+
+ fun copyUriToInternalStorage(
+ context: Context,
+ sourceUri: Uri?,
+ destinationParentPath: String,
+ destinationFilename: String
+ ): Boolean {
+ var input: InputStream? = null
+ var output: FileOutputStream? = null
+ try {
+ input = context.contentResolver.openInputStream(sourceUri!!)
+ output = FileOutputStream("$destinationParentPath/$destinationFilename")
+ val buffer = ByteArray(1024)
+ var len: Int
+ while (input!!.read(buffer).also { len = it } != -1) {
+ output.write(buffer, 0, len)
+ }
+ output.flush()
+ return true
+ } catch (e: Exception) {
+ Log.error("[FileUtil]: Cannot copy file, error: " + e.message)
+ } finally {
+ if (input != null) {
+ try {
+ input.close()
+ } catch (e: IOException) {
+ Log.error("[FileUtil]: Cannot close input file, error: " + e.message)
+ }
+ }
+ if (output != null) {
+ try {
+ output.close()
+ } catch (e: IOException) {
+ Log.error("[FileUtil]: Cannot close output file, error: " + e.message)
+ }
+ }
+ }
+ return false
+ }
+
+ /**
+ * Extracts the given zip file into the given directory.
+ * @exception IOException if the file was being created outside of the target directory
+ */
+ @Throws(SecurityException::class)
+ fun unzip(zipStream: InputStream, destDir: File): Boolean {
+ ZipInputStream(BufferedInputStream(zipStream)).use { zis ->
+ var entry: ZipEntry? = zis.nextEntry
+ while (entry != null) {
+ val entryName = entry.name
+ val entryFile = File(destDir, entryName)
+ if (!entryFile.canonicalPath.startsWith(destDir.canonicalPath + File.separator)) {
+ throw SecurityException("Entry is outside of the target dir: " + entryFile.name)
+ }
+ if (entry.isDirectory) {
+ entryFile.mkdirs()
+ } else {
+ entryFile.parentFile?.mkdirs()
+ entryFile.createNewFile()
+ entryFile.outputStream().use { fos -> zis.copyTo(fos) }
+ }
+ entry = zis.nextEntry
+ }
+ }
+
+ return true
+ }
+
+ fun isRootTreeUri(uri: Uri): Boolean {
+ val paths = uri.pathSegments
+ return paths.size == 2 && PATH_TREE == paths[0]
+ }
+
+ fun closeQuietly(closeable: AutoCloseable?) {
+ if (closeable != null) {
+ try {
+ closeable.close()
+ } catch (rethrown: RuntimeException) {
+ throw rethrown
+ } catch (ignored: Exception) {
+ }
+ }
+ }
+
+ fun hasExtension(path: String, extension: String): Boolean {
+ return path.substring(path.lastIndexOf(".") + 1).contains(extension)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
new file mode 100644
index 000000000..dc9b7c744
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.activities.EmulationActivity
+
+/**
+ * A service that shows a permanent notification in the background to avoid the app getting
+ * cleared from memory by the system.
+ */
+class ForegroundService : Service() {
+ companion object {
+ const val EMULATION_RUNNING_NOTIFICATION = 0x1000
+
+ const val ACTION_STOP = "stop"
+ }
+
+ private fun showRunningNotification() {
+ // Intent is used to resume emulation if the notification is clicked
+ val contentIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, EmulationActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ val builder =
+ NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id))
+ .setSmallIcon(R.drawable.ic_stat_notification_logo)
+ .setContentTitle(getString(R.string.app_name))
+ .setContentText(getString(R.string.emulation_notification_running))
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOngoing(true)
+ .setVibrate(null)
+ .setSound(null)
+ .setContentIntent(contentIntent)
+ startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build())
+ }
+
+ override fun onBind(intent: Intent): IBinder? {
+ return null
+ }
+
+ override fun onCreate() {
+ showRunningNotification()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent == null) {
+ return START_NOT_STICKY;
+ }
+ if (intent.action == ACTION_STOP) {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelfResult(startId)
+ }
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
new file mode 100644
index 000000000..ba6b5783e
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.SharedPreferences
+import android.net.Uri
+import androidx.preference.PreferenceManager
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.model.Game
+import java.util.*
+
+object GameHelper {
+ const val KEY_GAME_PATH = "game_path"
+ const val KEY_GAMES = "Games"
+
+ private lateinit var preferences: SharedPreferences
+
+ fun getGames(): List {
+ val games = mutableListOf()
+ val context = YuzuApplication.appContext
+ val gamesDir =
+ PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
+ val gamesUri = Uri.parse(gamesDir)
+ preferences = PreferenceManager.getDefaultSharedPreferences(context)
+
+ // Ensure keys are loaded so that ROM metadata can be decrypted.
+ NativeLibrary.reloadKeys()
+
+ val children = FileUtil.listFiles(context, gamesUri)
+ for (file in children) {
+ if (!file.isDirectory) {
+ val filename = file.uri.toString()
+ val extensionStart = filename.lastIndexOf('.')
+ if (extensionStart > 0) {
+ val fileExtension = filename.substring(extensionStart)
+
+ // Check that the file has an extension we care about before trying to read out of it.
+ if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
+ games.add(getGame(filename))
+ }
+ }
+ }
+ }
+
+ // Cache list of games found on disk
+ val serializedGames = mutableSetOf()
+ games.forEach {
+ serializedGames.add(Json.encodeToString(it))
+ }
+ preferences.edit()
+ .remove(KEY_GAMES)
+ .putStringSet(KEY_GAMES, serializedGames)
+ .apply()
+
+ return games.toList()
+ }
+
+ private fun getGame(filePath: String): Game {
+ var name = NativeLibrary.getTitle(filePath)
+
+ // If the game's title field is empty, use the filename.
+ if (name.isEmpty()) {
+ name = filePath.substring(filePath.lastIndexOf("/") + 1)
+ }
+ var gameId = NativeLibrary.getGameId(filePath)
+
+ // If the game's ID field is empty, use the filename without extension.
+ if (gameId.isEmpty()) {
+ gameId = filePath.substring(
+ filePath.lastIndexOf("/") + 1,
+ filePath.lastIndexOf(".")
+ )
+ }
+
+ val newGame = Game(
+ name,
+ NativeLibrary.getDescription(filePath).replace("\n", " "),
+ NativeLibrary.getRegions(filePath),
+ filePath,
+ gameId,
+ NativeLibrary.getCompany(filePath)
+ )
+
+ val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
+ if (addedTime == 0L) {
+ preferences.edit()
+ .putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
+ .apply()
+ }
+
+ return newGame
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
new file mode 100644
index 000000000..528011d7f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
@@ -0,0 +1,152 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Context
+import android.net.Uri
+import org.yuzu.yuzu_emu.NativeLibrary
+import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.zip.ZipInputStream
+
+object GpuDriverHelper {
+ private const val META_JSON_FILENAME = "meta.json"
+ private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip"
+ private var fileRedirectionPath: String? = null
+ private var driverInstallationPath: String? = null
+ private var hookLibPath: String? = null
+
+ @Throws(IOException::class)
+ private fun unzip(zipFilePath: String, destDir: String) {
+ val dir = File(destDir)
+
+ // Create output directory if it doesn't exist
+ if (!dir.exists()) dir.mkdirs()
+
+ // Unpack the files.
+ val inputStream = FileInputStream(zipFilePath)
+ val zis = ZipInputStream(BufferedInputStream(inputStream))
+ val buffer = ByteArray(1024)
+ var ze = zis.nextEntry
+ while (ze != null) {
+ val newFile = File(destDir, ze.name)
+ val canonicalPath = newFile.canonicalPath
+ if (!canonicalPath.startsWith(destDir + ze.name)) {
+ throw SecurityException("Zip file attempted path traversal! " + ze.name)
+ }
+
+ newFile.parentFile!!.mkdirs()
+ val fos = FileOutputStream(newFile)
+ var len: Int
+ while (zis.read(buffer).also { len = it } > 0) {
+ fos.write(buffer, 0, len)
+ }
+ fos.close()
+ zis.closeEntry()
+ ze = zis.nextEntry
+ }
+ zis.closeEntry()
+ }
+
+ fun initializeDriverParameters(context: Context) {
+ try {
+ // Initialize the file redirection directory.
+ fileRedirectionPath =
+ context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/"
+
+ // Initialize the driver installation directory.
+ driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/"
+ } catch (e: IOException) {
+ throw RuntimeException(e)
+ }
+
+ // Initialize directories.
+ initializeDirectories()
+
+ // Initialize hook libraries directory.
+ hookLibPath = context.applicationInfo.nativeLibraryDir + "/"
+
+ // Initialize GPU driver.
+ NativeLibrary.initializeGpuDriver(
+ hookLibPath,
+ driverInstallationPath,
+ customDriverLibraryName,
+ fileRedirectionPath
+ )
+ }
+
+ fun installDefaultDriver(context: Context) {
+ // Removing the installed driver will result in the backend using the default system driver.
+ val driverInstallationDir = File(driverInstallationPath!!)
+ deleteRecursive(driverInstallationDir)
+ initializeDriverParameters(context)
+ }
+
+ fun installCustomDriver(context: Context, driverPathUri: Uri?) {
+ // Revert to system default in the event the specified driver is bad.
+ installDefaultDriver(context)
+
+ // Ensure we have directories.
+ initializeDirectories()
+
+ // Copy the zip file URI into our private storage.
+ copyUriToInternalStorage(
+ context,
+ driverPathUri,
+ driverInstallationPath!!,
+ DRIVER_INTERNAL_FILENAME
+ )
+
+ // Unzip the driver.
+ try {
+ unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!)
+ } catch (e: SecurityException) {
+ return
+ }
+
+ // Initialize the driver parameters.
+ initializeDriverParameters(context)
+ }
+
+ // Parse the custom driver metadata to retrieve the name.
+ val customDriverName: String?
+ get() {
+ val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
+ return metadata.name
+ }
+
+ // Parse the custom driver metadata to retrieve the library name.
+ private val customDriverLibraryName: String?
+ get() {
+ // Parse the custom driver metadata to retrieve the library name.
+ val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
+ return metadata.libraryName
+ }
+
+ private fun initializeDirectories() {
+ // Ensure the file redirection directory exists.
+ val fileRedirectionDir = File(fileRedirectionPath!!)
+ if (!fileRedirectionDir.exists()) {
+ fileRedirectionDir.mkdirs()
+ }
+ // Ensure the driver installation directory exists.
+ val driverInstallationDir = File(driverInstallationPath!!)
+ if (!driverInstallationDir.exists()) {
+ driverInstallationDir.mkdirs()
+ }
+ }
+
+ private fun deleteRecursive(fileOrDirectory: File) {
+ if (fileOrDirectory.isDirectory) {
+ for (child in fileOrDirectory.listFiles()!!) {
+ deleteRecursive(child)
+ }
+ }
+ fileOrDirectory.delete()
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
new file mode 100644
index 000000000..70bdb4097
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
@@ -0,0 +1,47 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import org.json.JSONException
+import org.json.JSONObject
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Paths
+
+class GpuDriverMetadata(metadataFilePath: String) {
+ var name: String? = null
+ var description: String? = null
+ var author: String? = null
+ var vendor: String? = null
+ var driverVersion: String? = null
+ var minApi = 0
+ var libraryName: String? = null
+
+ init {
+ try {
+ val json = JSONObject(getStringFromFile(metadataFilePath))
+ name = json.getString("name")
+ description = json.getString("description")
+ author = json.getString("author")
+ vendor = json.getString("vendor")
+ driverVersion = json.getString("driverVersion")
+ minApi = json.getInt("minApi")
+ libraryName = json.getString("libraryName")
+ } catch (e: JSONException) {
+ // JSON is malformed, ignore and treat as unsupported metadata.
+ } catch (e: IOException) {
+ // File is inaccessible, ignore and treat as unsupported metadata.
+ }
+ }
+
+ companion object {
+ @Throws(IOException::class)
+ private fun getStringFromFile(filePath: String): String {
+ val path = Paths.get(filePath)
+ val bytes = Files.readAllBytes(path)
+ return String(bytes, StandardCharsets.UTF_8)
+ }
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
new file mode 100644
index 000000000..24e999b29
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
@@ -0,0 +1,360 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.view.KeyEvent
+import android.view.MotionEvent
+import org.yuzu.yuzu_emu.NativeLibrary
+import kotlin.math.sqrt
+
+class InputHandler {
+ fun initialize() {
+ // Connect first controller
+ NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device))
+ }
+
+ fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val button: Int = when (event.device.vendorId) {
+ 0x045E -> getInputXboxButtonKey(event.keyCode)
+ 0x054C -> getInputDS5ButtonKey(event.keyCode)
+ 0x057E -> getInputJoyconButtonKey(event.keyCode)
+ 0x1532 -> getInputRazerButtonKey(event.keyCode)
+ else -> getInputGenericButtonKey(event.keyCode)
+ }
+
+ val action = when (event.action) {
+ KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED
+ KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED
+ else -> return false
+ }
+
+ // Ignore invalid buttons
+ if (button < 0) {
+ return false
+ }
+
+ return NativeLibrary.onGamePadButtonEvent(
+ getPlayerNumber(event.device.controllerNumber),
+ button,
+ action
+ )
+ }
+
+ fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
+ val device = event.device
+ // Check every axis input available on the controller
+ for (range in device.motionRanges) {
+ val axis = range.axis
+ when (device.vendorId) {
+ 0x045E -> setGenericAxisInput(event, axis)
+ 0x054C -> setGenericAxisInput(event, axis)
+ 0x057E -> setJoyconAxisInput(event, axis)
+ 0x1532 -> setRazerAxisInput(event, axis)
+ else -> setGenericAxisInput(event, axis)
+ }
+ }
+
+ return true
+ }
+
+ private fun getPlayerNumber(index: Int): Int {
+ // TODO: Joycons are handled as different controllers. Find a way to merge them.
+ return when (index) {
+ 2 -> NativeLibrary.Player2Device
+ 3 -> NativeLibrary.Player3Device
+ 4 -> NativeLibrary.Player4Device
+ 5 -> NativeLibrary.Player5Device
+ 6 -> NativeLibrary.Player6Device
+ 7 -> NativeLibrary.Player7Device
+ 8 -> NativeLibrary.Player8Device
+ else -> if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device
+ }
+ }
+
+ private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) {
+ // Calculate vector size
+ val r2 = xAxis * xAxis + yAxis * yAxis
+ var r = sqrt(r2.toDouble()).toFloat()
+
+ // Adjust range of joystick
+ val deadzone = 0.15f
+ var x = xAxis
+ var y = yAxis
+
+ if (r > deadzone) {
+ val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone)
+ x *= deadzoneFactor
+ y *= deadzoneFactor
+ r *= deadzoneFactor
+ } else {
+ x = 0.0f
+ y = 0.0f
+ }
+
+ // Normalize joystick
+ if (r > 1.0f) {
+ x /= r
+ y /= r
+ }
+
+ NativeLibrary.onGamePadJoystickEvent(
+ playerNumber,
+ index,
+ x,
+ -y
+ )
+ }
+
+ private fun getAxisToButton(axis: Float): Int {
+ return if (axis > 0.5f) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED
+ }
+
+ private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) {
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_UP,
+ getAxisToButton(-yAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_DOWN,
+ getAxisToButton(yAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_LEFT,
+ getAxisToButton(-xAxis)
+ )
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.DPAD_RIGHT,
+ getAxisToButton(xAxis)
+ )
+ }
+
+ private fun getInputDS5ButtonKey(key: Int): Int {
+ // The missing ds5 buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputJoyconButtonKey(key: Int): Int {
+ // Joycon support is half dead. A lot of buttons can't be mapped
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
+ KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
+ KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
+ KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
+ KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputXboxButtonKey(key: Int): Int {
+ // The missing xbox buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputRazerButtonKey(key: Int): Int {
+ // The missing xbox buttons are axis
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun getInputGenericButtonKey(key: Int): Int {
+ return when (key) {
+ KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
+ KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
+ KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
+ KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
+ KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
+ KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
+ KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
+ KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
+ KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
+ KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
+ KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
+ KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
+ KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
+ KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
+ else -> -1
+ }
+ }
+
+ private fun setGenericAxisInput(event: MotionEvent, axis: Int) {
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_RX),
+ event.getAxisValue(MotionEvent.AXIS_RY)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_LTRIGGER ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER))
+ )
+ MotionEvent.AXIS_BRAKE ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
+ )
+ MotionEvent.AXIS_RTRIGGER ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER))
+ )
+ MotionEvent.AXIS_GAS ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
+ )
+ MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
+ setAxisDpadState(
+ playerNumber,
+ event.getAxisValue(MotionEvent.AXIS_HAT_X),
+ event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ )
+ }
+ }
+
+
+ private fun setJoyconAxisInput(event: MotionEvent, axis: Int) {
+ // Joycon support is half dead. Right joystick doesn't work
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_RX),
+ event.getAxisValue(MotionEvent.AXIS_RY)
+ )
+ }
+ }
+
+ private fun setRazerAxisInput(event: MotionEvent, axis: Int) {
+ val playerNumber = getPlayerNumber(event.device.controllerNumber)
+
+ when (axis) {
+ MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_L,
+ event.getAxisValue(MotionEvent.AXIS_X),
+ event.getAxisValue(MotionEvent.AXIS_Y)
+ )
+ MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
+ setStickState(
+ playerNumber,
+ NativeLibrary.StickType.STICK_R,
+ event.getAxisValue(MotionEvent.AXIS_Z),
+ event.getAxisValue(MotionEvent.AXIS_RZ)
+ )
+ MotionEvent.AXIS_BRAKE ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZL,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
+ )
+ MotionEvent.AXIS_GAS ->
+ NativeLibrary.onGamePadButtonEvent(
+ playerNumber,
+ NativeLibrary.ButtonType.TRIGGER_ZR,
+ getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
+ )
+ MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
+ setAxisDpadState(
+ playerNumber,
+ event.getAxisValue(MotionEvent.AXIS_HAT_X),
+ event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ )
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
new file mode 100644
index 000000000..19c53c481
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
@@ -0,0 +1,31 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.content.Context
+import android.graphics.Rect
+
+object InsetsHelper {
+ const val THREE_BUTTON_NAVIGATION = 0
+ const val TWO_BUTTON_NAVIGATION = 1
+ const val GESTURE_NAVIGATION = 2
+
+ @SuppressLint("DiscouragedApi")
+ fun getSystemGestureType(context: Context): Int {
+ val resources = context.resources
+ val resourceId =
+ resources.getIdentifier("config_navBarInteractionMode", "integer", "android")
+ return if (resourceId != 0) {
+ resources.getInteger(resourceId)
+ } else 0
+ }
+
+ fun getBottomPaddingRequired(activity: Activity): Int {
+ val visibleFrame = Rect()
+ activity.window.decorView.getWindowVisibleDisplayFrame(visibleFrame)
+ return visibleFrame.bottom - visibleFrame.top - activity.resources.displayMetrics.heightPixels
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
new file mode 100644
index 000000000..a193e82a4
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.util.Log
+import org.yuzu.yuzu_emu.BuildConfig
+
+/**
+ * Contains methods that call through to [android.util.Log], but
+ * with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log
+ * levels in release builds.
+ */
+object Log {
+ private const val TAG = "Yuzu Frontend"
+
+ fun verbose(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.v(TAG, message)
+ }
+ }
+
+ fun debug(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, message)
+ }
+ }
+
+ fun info(message: String) {
+ Log.i(TAG, message)
+ }
+
+ fun warning(message: String) {
+ Log.w(TAG, message)
+ }
+
+ fun error(message: String) {
+ Log.e(TAG, message)
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
new file mode 100644
index 000000000..344dd8a0a
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
@@ -0,0 +1,168 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.Activity
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.IntentFilter
+import android.nfc.NfcAdapter
+import android.nfc.Tag
+import android.nfc.tech.NfcA
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import org.yuzu.yuzu_emu.NativeLibrary
+import java.io.IOException
+
+class NfcReader(private val activity: Activity) {
+ private var nfcAdapter: NfcAdapter? = null
+ private var pendingIntent: PendingIntent? = null
+
+ fun initialize() {
+ nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return
+
+ pendingIntent = PendingIntent.getActivity(
+ activity,
+ 0, Intent(activity, activity.javaClass),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
+ else PendingIntent.FLAG_UPDATE_CURRENT
+ )
+
+ val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
+ tagDetected.addCategory(Intent.CATEGORY_DEFAULT)
+ }
+
+ fun startScanning() {
+ nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null)
+ }
+
+ fun stopScanning() {
+ nfcAdapter?.disableForegroundDispatch(activity)
+ }
+
+ fun onNewIntent(intent: Intent) {
+ val action = intent.action
+ if (NfcAdapter.ACTION_TAG_DISCOVERED != action
+ && NfcAdapter.ACTION_TECH_DISCOVERED != action
+ && NfcAdapter.ACTION_NDEF_DISCOVERED != action
+ ) {
+ return
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val tag =
+ intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return
+ readTagData(tag)
+ return
+ }
+
+ val tag =
+ intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return
+ readTagData(tag)
+ }
+
+ private fun readTagData(tag: Tag) {
+ if (!tag.techList.contains("android.nfc.tech.NfcA")) {
+ return
+ }
+
+ val amiibo = NfcA.get(tag) ?: return
+ amiibo.connect()
+
+ val tagData = ntag215ReadAll(amiibo) ?: return
+ NativeLibrary.onReadNfcTag(tagData)
+
+ nfcAdapter?.ignore(
+ tag,
+ 1000,
+ { NativeLibrary.onRemoveNfcTag() },
+ Handler(Looper.getMainLooper())
+ )
+ }
+
+ private fun ntag215ReadAll(amiibo: NfcA): ByteArray? {
+ val bufferSize = amiibo.maxTransceiveLength;
+ val tagSize = 0x21C
+ val pageSize = 4
+ val lastPage = tagSize / pageSize - 1
+ val tagData = ByteArray(tagSize)
+
+ // We need to read the ntag in steps otherwise we overflow the buffer
+ for (i in 0..tagSize step bufferSize - 1) {
+ val dataStart = i / pageSize
+ var dataEnd = (i + bufferSize) / pageSize
+
+ if (dataEnd > lastPage) {
+ dataEnd = lastPage
+ }
+
+ try {
+ val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1)
+ System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize)
+ } catch (e: IOException) {
+ return null;
+ }
+ }
+ return tagData
+ }
+
+ private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x30.toByte(),
+ (page and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x3A.toByte(),
+ (start and 0xFF).toByte(),
+ (end and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215PWrite(
+ amiibo: NfcA,
+ page: Int,
+ data1: Int,
+ data2: Int,
+ data3: Int,
+ data4: Int
+ ): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0xA2.toByte(),
+ (page and 0xFF).toByte(),
+ (data1 and 0xFF).toByte(),
+ (data2 and 0xFF).toByte(),
+ (data3 and 0xFF).toByte(),
+ (data4 and 0xFF).toByte()
+ )
+ )
+ }
+
+ private fun ntag215PwdAuth(
+ amiibo: NfcA,
+ data1: Int,
+ data2: Int,
+ data3: Int,
+ data4: Int
+ ): ByteArray? {
+ return amiibo.transceive(
+ byteArrayOf(
+ 0x1B.toByte(),
+ (data1 and 0xFF).toByte(),
+ (data2 and 0xFF).toByte(),
+ (data3 and 0xFF).toByte(),
+ (data4 and 0xFF).toByte()
+ )
+ )
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt
new file mode 100644
index 000000000..87ee7f2e6
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/SerializableHelper.kt
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Parcelable
+import java.io.Serializable
+
+object SerializableHelper {
+ inline fun Bundle.serializable(key: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ getSerializable(key, T::class.java)
+ else
+ getSerializable(key) as? T
+ }
+
+ inline fun Intent.serializable(key: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ getSerializableExtra(key, T::class.java)
+ else
+ getSerializableExtra(key) as? T
+ }
+
+ inline fun Bundle.parcelable(key: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ getParcelable(key, T::class.java)
+ else
+ getParcelable(key) as? T
+ }
+
+ inline fun Intent.parcelable(key: String): T? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ getParcelableExtra(key, T::class.java)
+ else
+ getParcelableExtra(key) as? T
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt
new file mode 100644
index 000000000..e55767c0f
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ThemeHelper.kt
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.utils
+
+import android.app.Activity
+import android.content.res.Configuration
+import android.graphics.Color
+import androidx.annotation.ColorInt
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.ContextCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.preference.PreferenceManager
+import org.yuzu.yuzu_emu.R
+import org.yuzu.yuzu_emu.YuzuApplication
+import org.yuzu.yuzu_emu.features.settings.model.Settings
+import org.yuzu.yuzu_emu.ui.main.ThemeProvider
+import kotlin.math.roundToInt
+
+object ThemeHelper {
+ const val SYSTEM_BAR_ALPHA = 0.9f
+
+ private const val DEFAULT = 0
+ private const val MATERIAL_YOU = 1
+
+ fun setTheme(activity: AppCompatActivity) {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
+ setThemeMode(activity)
+ when (preferences.getInt(Settings.PREF_THEME, 0)) {
+ DEFAULT -> activity.setTheme(R.style.Theme_Yuzu_Main)
+ MATERIAL_YOU -> activity.setTheme(R.style.Theme_Yuzu_Main_MaterialYou)
+ }
+
+ // Using a specific night mode check because this could apply incorrectly when using the
+ // light app mode, dark system mode, and black backgrounds. Launching the settings activity
+ // will then show light mode colors/navigation bars but with black backgrounds.
+ if (preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
+ && isNightMode(activity)
+ ) {
+ activity.setTheme(R.style.ThemeOverlay_Yuzu_Dark)
+ }
+ }
+
+ @ColorInt
+ fun getColorWithOpacity(@ColorInt color: Int, alphaFactor: Float): Int {
+ return Color.argb(
+ (alphaFactor * Color.alpha(color)).roundToInt(), Color.red(color),
+ Color.green(color), Color.blue(color)
+ )
+ }
+
+ fun setCorrectTheme(activity: AppCompatActivity) {
+ val currentTheme = (activity as ThemeProvider).themeId
+ setTheme(activity)
+ if (currentTheme != (activity as ThemeProvider).themeId) {
+ activity.recreate()
+ }
+ }
+
+ fun setThemeMode(activity: AppCompatActivity) {
+ val themeMode = PreferenceManager.getDefaultSharedPreferences(activity.applicationContext)
+ .getInt(Settings.PREF_THEME_MODE, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ activity.delegate.localNightMode = themeMode
+ val windowController = WindowCompat.getInsetsController(
+ activity.window,
+ activity.window.decorView
+ )
+ when (themeMode) {
+ AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> when (isNightMode(activity)) {
+ false -> setLightModeSystemBars(windowController)
+ true -> setDarkModeSystemBars(windowController)
+ }
+ AppCompatDelegate.MODE_NIGHT_NO -> setLightModeSystemBars(windowController)
+ AppCompatDelegate.MODE_NIGHT_YES -> setDarkModeSystemBars(windowController)
+ }
+ }
+
+ private fun isNightMode(activity: AppCompatActivity): Boolean {
+ return when (activity.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
+ Configuration.UI_MODE_NIGHT_NO -> false
+ Configuration.UI_MODE_NIGHT_YES -> true
+ else -> false
+ }
+ }
+
+ private fun setLightModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = true
+ windowController.isAppearanceLightNavigationBars = true
+ }
+
+ private fun setDarkModeSystemBars(windowController: WindowInsetsControllerCompat) {
+ windowController.isAppearanceLightStatusBars = false
+ windowController.isAppearanceLightNavigationBars = false
+ }
+}
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt
new file mode 100644
index 000000000..c8ef8c1fd
--- /dev/null
+++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/views/FixedRatioSurfaceView.kt
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+package org.yuzu.yuzu_emu.views
+
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Rational
+import android.view.SurfaceView
+import kotlin.math.roundToInt
+
+class FixedRatioSurfaceView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : SurfaceView(context, attrs, defStyleAttr) {
+ private var aspectRatio: Float = 0f // (width / height), 0f is a special value for stretch
+
+ /**
+ * Sets the desired aspect ratio for this view
+ * @param ratio the ratio to force the view to, or null to stretch to fit
+ */
+ fun setAspectRatio(ratio: Rational?) {
+ aspectRatio = ratio?.toFloat() ?: 0f
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
+ val width = MeasureSpec.getSize(widthMeasureSpec)
+ val height = MeasureSpec.getSize(heightMeasureSpec)
+ if (aspectRatio != 0f) {
+ val newWidth: Int
+ val newHeight: Int
+ if (height * aspectRatio < width) {
+ newWidth = (height * aspectRatio).roundToInt()
+ newHeight = height
+ } else {
+ newWidth = width
+ newHeight = (width / aspectRatio).roundToInt()
+ }
+ setMeasuredDimension(newWidth, newHeight)
+ } else {
+ setMeasuredDimension(width, height)
+ }
+ }
+}
diff --git a/src/android/app/src/main/jni/CMakeLists.txt b/src/android/app/src/main/jni/CMakeLists.txt
new file mode 100644
index 000000000..041781577
--- /dev/null
+++ b/src/android/app/src/main/jni/CMakeLists.txt
@@ -0,0 +1,28 @@
+# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+add_library(yuzu-android SHARED
+ android_common/android_common.cpp
+ android_common/android_common.h
+ applets/software_keyboard.cpp
+ applets/software_keyboard.h
+ config.cpp
+ config.h
+ default_ini.h
+ emu_window/emu_window.cpp
+ emu_window/emu_window.h
+ id_cache.cpp
+ id_cache.h
+ native.cpp
+ native.h
+)
+
+set_property(TARGET yuzu-android PROPERTY IMPORTED_LOCATION ${FFmpeg_LIBRARY_DIR})
+
+target_link_libraries(yuzu-android PRIVATE audio_core common core input_common)
+target_link_libraries(yuzu-android PRIVATE android camera2ndk EGL glad inih jnigraphics log)
+if (ARCHITECTURE_arm64)
+ target_link_libraries(yuzu-android PRIVATE adrenotools)
+endif()
+
+set(CPACK_PACKAGE_EXECUTABLES ${CPACK_PACKAGE_EXECUTABLES} yuzu-android)
diff --git a/src/android/app/src/main/jni/android_common/android_common.cpp b/src/android/app/src/main/jni/android_common/android_common.cpp
new file mode 100644
index 000000000..52d8ecfeb
--- /dev/null
+++ b/src/android/app/src/main/jni/android_common/android_common.cpp
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include "jni/android_common/android_common.h"
+
+#include
+#include
+
+#include
+
+#include "common/string_util.h"
+
+std::string GetJString(JNIEnv* env, jstring jstr) {
+ if (!jstr) {
+ return {};
+ }
+
+ const jchar* jchars = env->GetStringChars(jstr, nullptr);
+ const jsize length = env->GetStringLength(jstr);
+ const std::u16string_view string_view(reinterpret_cast(jchars), length);
+ const std::string converted_string = Common::UTF16ToUTF8(string_view);
+ env->ReleaseStringChars(jstr, jchars);
+
+ return converted_string;
+}
+
+jstring ToJString(JNIEnv* env, std::string_view str) {
+ const std::u16string converted_string = Common::UTF8ToUTF16(str);
+ return env->NewString(reinterpret_cast(converted_string.data()),
+ static_cast(converted_string.size()));
+}
+
+jstring ToJString(JNIEnv* env, std::u16string_view str) {
+ return ToJString(env, Common::UTF16ToUTF8(str));
+}
diff --git a/src/android/app/src/main/jni/android_common/android_common.h b/src/android/app/src/main/jni/android_common/android_common.h
new file mode 100644
index 000000000..ccb0c06f7
--- /dev/null
+++ b/src/android/app/src/main/jni/android_common/android_common.h
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#pragma once
+
+#include
+
+#include
+
+std::string GetJString(JNIEnv* env, jstring jstr);
+jstring ToJString(JNIEnv* env, std::string_view str);
+jstring ToJString(JNIEnv* env, std::u16string_view str);
diff --git a/src/android/app/src/main/jni/applets/software_keyboard.cpp b/src/android/app/src/main/jni/applets/software_keyboard.cpp
new file mode 100644
index 000000000..74e040478
--- /dev/null
+++ b/src/android/app/src/main/jni/applets/software_keyboard.cpp
@@ -0,0 +1,277 @@
+// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+#include