Merge pull request #10508 from yuzu-emu/lime
Project Lime - yuzu Android Port
This commit is contained in:
		
							
								
								
									
										15
									
								
								.ci/scripts/android/build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										15
									
								
								.ci/scripts/android/build.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| #!/bin/bash -ex | ||||
|  | ||||
| # SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| export NDK_CCACHE="$(which ccache)" | ||||
| ccache -s | ||||
|  | ||||
| BUILD_FLAVOR=mainline | ||||
|  | ||||
| cd src/android | ||||
| chmod +x ./gradlew | ||||
| ./gradlew "assemble${BUILD_FLAVOR}Release" "bundle${BUILD_FLAVOR}Release" | ||||
|  | ||||
| ccache -s | ||||
							
								
								
									
										27
									
								
								.ci/scripts/android/upload.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										27
									
								
								.ci/scripts/android/upload.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -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 | ||||
| @@ -122,3 +122,62 @@ jobs: | ||||
|         with: | ||||
|           name: ${{ env.INDIVIDUAL_EXE }} | ||||
|           path: ${{ env.INDIVIDUAL_EXE }} | ||||
|   android: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: format | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|         with: | ||||
|           submodules: recursive | ||||
|       - name: Set up cache | ||||
|         uses: actions/cache@v3 | ||||
|         with: | ||||
|           path: | | ||||
|             ~/.gradle/caches | ||||
|             ~/.gradle/wrapper | ||||
|             ~/.ccache | ||||
|           key: ${{ runner.os }}-android-${{ github.sha }} | ||||
|           restore-keys: | | ||||
|             ${{ runner.os }}-android- | ||||
|       - name: Query tag name | ||||
|         uses: olegtarasov/get-tag@v2.1.2 | ||||
|         id: tagName | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           sudo apt-get update | ||||
|           sudo apt-get install -y ccache apksigner glslang-dev glslang-tools | ||||
|           git -C ./externals/vcpkg/ fetch --all --unshallow | ||||
|       - name: Build | ||||
|         run: ./.ci/scripts/android/build.sh | ||||
|       - name: Copy and sign artifacts | ||||
|         env: | ||||
|           ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} | ||||
|           ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} | ||||
|           ANDROID_KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASS }} | ||||
|         run: ./.ci/scripts/android/upload.sh | ||||
|       - name: Upload | ||||
|         uses: actions/upload-artifact@v3 | ||||
|         with: | ||||
|           name: android | ||||
|           path: artifacts/ | ||||
|   release: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: [ android ] | ||||
|     if: ${{ startsWith(github.ref, 'refs/tags/') }} | ||||
|     steps: | ||||
|       - uses: actions/download-artifact@v3 | ||||
|       - name: Create release | ||||
|         uses: actions/create-release@v1 | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|         with: | ||||
|           tag_name: ${{ github.ref_name }} | ||||
|           release_name: ${{ github.ref_name }} | ||||
|           draft: false | ||||
|           prerelease: false | ||||
|       - name: Upload artifacts | ||||
|         uses: alexellis/upload-assets@0.4.0 | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|         with: | ||||
|           asset_paths: '["./**/*.tar.*","./**/*.AppImage","./**/*.7z","./**/*.zip","./**/*.apk","./**/*.aab"]' | ||||
							
								
								
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
								
							| @@ -49,3 +49,6 @@ | ||||
| [submodule "cpp-jwt"] | ||||
| 	path = externals/cpp-jwt | ||||
| 	url = https://github.com/arun11299/cpp-jwt.git | ||||
| [submodule "externals/libadrenotools"] | ||||
| 	path = externals/libadrenotools | ||||
| 	url = https://github.com/bylaws/libadrenotools | ||||
|   | ||||
							
								
								
									
										12
									
								
								.reuse/dep5
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								.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 | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -7,6 +7,7 @@ | ||||
| #   prefix_var: name of a variable which will be set with the path to the extracted contents | ||||
| function(download_bundled_external remote_path lib_name prefix_var) | ||||
|  | ||||
| set(package_base_url "https://github.com/yuzu-emu/") | ||||
| set(package_repo "no_platform") | ||||
| set(package_extension "no_platform") | ||||
| if (WIN32) | ||||
| @@ -15,10 +16,13 @@ if (WIN32) | ||||
| elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux") | ||||
|     set(package_repo "ext-linux-bin/raw/main/") | ||||
|     set(package_extension ".tar.xz") | ||||
| elseif (ANDROID)     | ||||
|     set(package_repo "ext-android-bin/raw/main/") | ||||
|     set(package_extension ".tar.xz") | ||||
| else() | ||||
|     message(FATAL_ERROR "No package available for this platform") | ||||
| endif() | ||||
| set(package_url "https://github.com/yuzu-emu/${package_repo}") | ||||
| set(package_url "${package_base_url}${package_repo}") | ||||
|  | ||||
| set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}") | ||||
| if (NOT EXISTS "${prefix}") | ||||
|   | ||||
							
								
								
									
										373
									
								
								LICENSES/MPL-2.0.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								LICENSES/MPL-2.0.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,373 @@ | ||||
| Mozilla Public License Version 2.0 | ||||
| ================================== | ||||
|  | ||||
| 1. Definitions | ||||
| -------------- | ||||
|  | ||||
| 1.1. "Contributor" | ||||
|     means each individual or legal entity that creates, contributes to | ||||
|     the creation of, or owns Covered Software. | ||||
|  | ||||
| 1.2. "Contributor Version" | ||||
|     means the combination of the Contributions of others (if any) used | ||||
|     by a Contributor and that particular Contributor's Contribution. | ||||
|  | ||||
| 1.3. "Contribution" | ||||
|     means Covered Software of a particular Contributor. | ||||
|  | ||||
| 1.4. "Covered Software" | ||||
|     means Source Code Form to which the initial Contributor has attached | ||||
|     the notice in Exhibit A, the Executable Form of such Source Code | ||||
|     Form, and Modifications of such Source Code Form, in each case | ||||
|     including portions thereof. | ||||
|  | ||||
| 1.5. "Incompatible With Secondary Licenses" | ||||
|     means | ||||
|  | ||||
|     (a) that the initial Contributor has attached the notice described | ||||
|         in Exhibit B to the Covered Software; or | ||||
|  | ||||
|     (b) that the Covered Software was made available under the terms of | ||||
|         version 1.1 or earlier of the License, but not also under the | ||||
|         terms of a Secondary License. | ||||
|  | ||||
| 1.6. "Executable Form" | ||||
|     means any form of the work other than Source Code Form. | ||||
|  | ||||
| 1.7. "Larger Work" | ||||
|     means a work that combines Covered Software with other material, in  | ||||
|     a separate file or files, that is not Covered Software. | ||||
|  | ||||
| 1.8. "License" | ||||
|     means this document. | ||||
|  | ||||
| 1.9. "Licensable" | ||||
|     means having the right to grant, to the maximum extent possible, | ||||
|     whether at the time of the initial grant or subsequently, any and | ||||
|     all of the rights conveyed by this License. | ||||
|  | ||||
| 1.10. "Modifications" | ||||
|     means any of the following: | ||||
|  | ||||
|     (a) any file in Source Code Form that results from an addition to, | ||||
|         deletion from, or modification of the contents of Covered | ||||
|         Software; or | ||||
|  | ||||
|     (b) any new file in Source Code Form that contains any Covered | ||||
|         Software. | ||||
|  | ||||
| 1.11. "Patent Claims" of a Contributor | ||||
|     means any patent claim(s), including without limitation, method, | ||||
|     process, and apparatus claims, in any patent Licensable by such | ||||
|     Contributor that would be infringed, but for the grant of the | ||||
|     License, by the making, using, selling, offering for sale, having | ||||
|     made, import, or transfer of either its Contributions or its | ||||
|     Contributor Version. | ||||
|  | ||||
| 1.12. "Secondary License" | ||||
|     means either the GNU General Public License, Version 2.0, the GNU | ||||
|     Lesser General Public License, Version 2.1, the GNU Affero General | ||||
|     Public License, Version 3.0, or any later versions of those | ||||
|     licenses. | ||||
|  | ||||
| 1.13. "Source Code Form" | ||||
|     means the form of the work preferred for making modifications. | ||||
|  | ||||
| 1.14. "You" (or "Your") | ||||
|     means an individual or a legal entity exercising rights under this | ||||
|     License. For legal entities, "You" includes any entity that | ||||
|     controls, is controlled by, or is under common control with You. For | ||||
|     purposes of this definition, "control" means (a) the power, direct | ||||
|     or indirect, to cause the direction or management of such entity, | ||||
|     whether by contract or otherwise, or (b) ownership of more than | ||||
|     fifty percent (50%) of the outstanding shares or beneficial | ||||
|     ownership of such entity. | ||||
|  | ||||
| 2. License Grants and Conditions | ||||
| -------------------------------- | ||||
|  | ||||
| 2.1. Grants | ||||
|  | ||||
| Each Contributor hereby grants You a world-wide, royalty-free, | ||||
| non-exclusive license: | ||||
|  | ||||
| (a) under intellectual property rights (other than patent or trademark) | ||||
|     Licensable by such Contributor to use, reproduce, make available, | ||||
|     modify, display, perform, distribute, and otherwise exploit its | ||||
|     Contributions, either on an unmodified basis, with Modifications, or | ||||
|     as part of a Larger Work; and | ||||
|  | ||||
| (b) under Patent Claims of such Contributor to make, use, sell, offer | ||||
|     for sale, have made, import, and otherwise transfer either its | ||||
|     Contributions or its Contributor Version. | ||||
|  | ||||
| 2.2. Effective Date | ||||
|  | ||||
| The licenses granted in Section 2.1 with respect to any Contribution | ||||
| become effective for each Contribution on the date the Contributor first | ||||
| distributes such Contribution. | ||||
|  | ||||
| 2.3. Limitations on Grant Scope | ||||
|  | ||||
| The licenses granted in this Section 2 are the only rights granted under | ||||
| this License. No additional rights or licenses will be implied from the | ||||
| distribution or licensing of Covered Software under this License. | ||||
| Notwithstanding Section 2.1(b) above, no patent license is granted by a | ||||
| Contributor: | ||||
|  | ||||
| (a) for any code that a Contributor has removed from Covered Software; | ||||
|     or | ||||
|  | ||||
| (b) for infringements caused by: (i) Your and any other third party's | ||||
|     modifications of Covered Software, or (ii) the combination of its | ||||
|     Contributions with other software (except as part of its Contributor | ||||
|     Version); or | ||||
|  | ||||
| (c) under Patent Claims infringed by Covered Software in the absence of | ||||
|     its Contributions. | ||||
|  | ||||
| This License does not grant any rights in the trademarks, service marks, | ||||
| or logos of any Contributor (except as may be necessary to comply with | ||||
| the notice requirements in Section 3.4). | ||||
|  | ||||
| 2.4. Subsequent Licenses | ||||
|  | ||||
| No Contributor makes additional grants as a result of Your choice to | ||||
| distribute the Covered Software under a subsequent version of this | ||||
| License (see Section 10.2) or under the terms of a Secondary License (if | ||||
| permitted under the terms of Section 3.3). | ||||
|  | ||||
| 2.5. Representation | ||||
|  | ||||
| Each Contributor represents that the Contributor believes its | ||||
| Contributions are its original creation(s) or it has sufficient rights | ||||
| to grant the rights to its Contributions conveyed by this License. | ||||
|  | ||||
| 2.6. Fair Use | ||||
|  | ||||
| This License is not intended to limit any rights You have under | ||||
| applicable copyright doctrines of fair use, fair dealing, or other | ||||
| equivalents. | ||||
|  | ||||
| 2.7. Conditions | ||||
|  | ||||
| Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted | ||||
| in Section 2.1. | ||||
|  | ||||
| 3. Responsibilities | ||||
| ------------------- | ||||
|  | ||||
| 3.1. Distribution of Source Form | ||||
|  | ||||
| All distribution of Covered Software in Source Code Form, including any | ||||
| Modifications that You create or to which You contribute, must be under | ||||
| the terms of this License. You must inform recipients that the Source | ||||
| Code Form of the Covered Software is governed by the terms of this | ||||
| License, and how they can obtain a copy of this License. You may not | ||||
| attempt to alter or restrict the recipients' rights in the Source Code | ||||
| Form. | ||||
|  | ||||
| 3.2. Distribution of Executable Form | ||||
|  | ||||
| If You distribute Covered Software in Executable Form then: | ||||
|  | ||||
| (a) such Covered Software must also be made available in Source Code | ||||
|     Form, as described in Section 3.1, and You must inform recipients of | ||||
|     the Executable Form how they can obtain a copy of such Source Code | ||||
|     Form by reasonable means in a timely manner, at a charge no more | ||||
|     than the cost of distribution to the recipient; and | ||||
|  | ||||
| (b) You may distribute such Executable Form under the terms of this | ||||
|     License, or sublicense it under different terms, provided that the | ||||
|     license for the Executable Form does not attempt to limit or alter | ||||
|     the recipients' rights in the Source Code Form under this License. | ||||
|  | ||||
| 3.3. Distribution of a Larger Work | ||||
|  | ||||
| You may create and distribute a Larger Work under terms of Your choice, | ||||
| provided that You also comply with the requirements of this License for | ||||
| the Covered Software. If the Larger Work is a combination of Covered | ||||
| Software with a work governed by one or more Secondary Licenses, and the | ||||
| Covered Software is not Incompatible With Secondary Licenses, this | ||||
| License permits You to additionally distribute such Covered Software | ||||
| under the terms of such Secondary License(s), so that the recipient of | ||||
| the Larger Work may, at their option, further distribute the Covered | ||||
| Software under the terms of either this License or such Secondary | ||||
| License(s). | ||||
|  | ||||
| 3.4. Notices | ||||
|  | ||||
| You may not remove or alter the substance of any license notices | ||||
| (including copyright notices, patent notices, disclaimers of warranty, | ||||
| or limitations of liability) contained within the Source Code Form of | ||||
| the Covered Software, except that You may alter any license notices to | ||||
| the extent required to remedy known factual inaccuracies. | ||||
|  | ||||
| 3.5. Application of Additional Terms | ||||
|  | ||||
| You may choose to offer, and to charge a fee for, warranty, support, | ||||
| indemnity or liability obligations to one or more recipients of Covered | ||||
| Software. However, You may do so only on Your own behalf, and not on | ||||
| behalf of any Contributor. You must make it absolutely clear that any | ||||
| such warranty, support, indemnity, or liability obligation is offered by | ||||
| You alone, and You hereby agree to indemnify every Contributor for any | ||||
| liability incurred by such Contributor as a result of warranty, support, | ||||
| indemnity or liability terms You offer. You may include additional | ||||
| disclaimers of warranty and limitations of liability specific to any | ||||
| jurisdiction. | ||||
|  | ||||
| 4. Inability to Comply Due to Statute or Regulation | ||||
| --------------------------------------------------- | ||||
|  | ||||
| If it is impossible for You to comply with any of the terms of this | ||||
| License with respect to some or all of the Covered Software due to | ||||
| statute, judicial order, or regulation then You must: (a) comply with | ||||
| the terms of this License to the maximum extent possible; and (b) | ||||
| describe the limitations and the code they affect. Such description must | ||||
| be placed in a text file included with all distributions of the Covered | ||||
| Software under this License. Except to the extent prohibited by statute | ||||
| or regulation, such description must be sufficiently detailed for a | ||||
| recipient of ordinary skill to be able to understand it. | ||||
|  | ||||
| 5. Termination | ||||
| -------------- | ||||
|  | ||||
| 5.1. The rights granted under this License will terminate automatically | ||||
| if You fail to comply with any of its terms. However, if You become | ||||
| compliant, then the rights granted under this License from a particular | ||||
| Contributor are reinstated (a) provisionally, unless and until such | ||||
| Contributor explicitly and finally terminates Your grants, and (b) on an | ||||
| ongoing basis, if such Contributor fails to notify You of the | ||||
| non-compliance by some reasonable means prior to 60 days after You have | ||||
| come back into compliance. Moreover, Your grants from a particular | ||||
| Contributor are reinstated on an ongoing basis if such Contributor | ||||
| notifies You of the non-compliance by some reasonable means, this is the | ||||
| first time You have received notice of non-compliance with this License | ||||
| from such Contributor, and You become compliant prior to 30 days after | ||||
| Your receipt of the notice. | ||||
|  | ||||
| 5.2. If You initiate litigation against any entity by asserting a patent | ||||
| infringement claim (excluding declaratory judgment actions, | ||||
| counter-claims, and cross-claims) alleging that a Contributor Version | ||||
| directly or indirectly infringes any patent, then the rights granted to | ||||
| You by any and all Contributors for the Covered Software under Section | ||||
| 2.1 of this License shall terminate. | ||||
|  | ||||
| 5.3. In the event of termination under Sections 5.1 or 5.2 above, all | ||||
| end user license agreements (excluding distributors and resellers) which | ||||
| have been validly granted by You or Your distributors under this License | ||||
| prior to termination shall survive termination. | ||||
|  | ||||
| ************************************************************************ | ||||
| *                                                                      * | ||||
| *  6. Disclaimer of Warranty                                           * | ||||
| *  -------------------------                                           * | ||||
| *                                                                      * | ||||
| *  Covered Software is provided under this License on an "as is"       * | ||||
| *  basis, without warranty of any kind, either expressed, implied, or  * | ||||
| *  statutory, including, without limitation, warranties that the       * | ||||
| *  Covered Software is free of defects, merchantable, fit for a        * | ||||
| *  particular purpose or non-infringing. The entire risk as to the     * | ||||
| *  quality and performance of the Covered Software is with You.        * | ||||
| *  Should any Covered Software prove defective in any respect, You     * | ||||
| *  (not any Contributor) assume the cost of any necessary servicing,   * | ||||
| *  repair, or correction. This disclaimer of warranty constitutes an   * | ||||
| *  essential part of this License. No use of any Covered Software is   * | ||||
| *  authorized under this License except under this disclaimer.         * | ||||
| *                                                                      * | ||||
| ************************************************************************ | ||||
|  | ||||
| ************************************************************************ | ||||
| *                                                                      * | ||||
| *  7. Limitation of Liability                                          * | ||||
| *  --------------------------                                          * | ||||
| *                                                                      * | ||||
| *  Under no circumstances and under no legal theory, whether tort      * | ||||
| *  (including negligence), contract, or otherwise, shall any           * | ||||
| *  Contributor, or anyone who distributes Covered Software as          * | ||||
| *  permitted above, be liable to You for any direct, indirect,         * | ||||
| *  special, incidental, or consequential damages of any character      * | ||||
| *  including, without limitation, damages for lost profits, loss of    * | ||||
| *  goodwill, work stoppage, computer failure or malfunction, or any    * | ||||
| *  and all other commercial damages or losses, even if such party      * | ||||
| *  shall have been informed of the possibility of such damages. This   * | ||||
| *  limitation of liability shall not apply to liability for death or   * | ||||
| *  personal injury resulting from such party's negligence to the       * | ||||
| *  extent applicable law prohibits such limitation. Some               * | ||||
| *  jurisdictions do not allow the exclusion or limitation of           * | ||||
| *  incidental or consequential damages, so this exclusion and          * | ||||
| *  limitation may not apply to You.                                    * | ||||
| *                                                                      * | ||||
| ************************************************************************ | ||||
|  | ||||
| 8. Litigation | ||||
| ------------- | ||||
|  | ||||
| Any litigation relating to this License may be brought only in the | ||||
| courts of a jurisdiction where the defendant maintains its principal | ||||
| place of business and such litigation shall be governed by laws of that | ||||
| jurisdiction, without reference to its conflict-of-law provisions. | ||||
| Nothing in this Section shall prevent a party's ability to bring | ||||
| cross-claims or counter-claims. | ||||
|  | ||||
| 9. Miscellaneous | ||||
| ---------------- | ||||
|  | ||||
| This License represents the complete agreement concerning the subject | ||||
| matter hereof. If any provision of this License is held to be | ||||
| unenforceable, such provision shall be reformed only to the extent | ||||
| necessary to make it enforceable. Any law or regulation which provides | ||||
| that the language of a contract shall be construed against the drafter | ||||
| shall not be used to construe this License against a Contributor. | ||||
|  | ||||
| 10. Versions of the License | ||||
| --------------------------- | ||||
|  | ||||
| 10.1. New Versions | ||||
|  | ||||
| Mozilla Foundation is the license steward. Except as provided in Section | ||||
| 10.3, no one other than the license steward has the right to modify or | ||||
| publish new versions of this License. Each version will be given a | ||||
| distinguishing version number. | ||||
|  | ||||
| 10.2. Effect of New Versions | ||||
|  | ||||
| You may distribute the Covered Software under the terms of the version | ||||
| of the License under which You originally received the Covered Software, | ||||
| or under the terms of any subsequent version published by the license | ||||
| steward. | ||||
|  | ||||
| 10.3. Modified Versions | ||||
|  | ||||
| If you create software not governed by this License, and you want to | ||||
| create a new license for such software, you may create and use a | ||||
| modified version of this License if you rename the license and remove | ||||
| any references to the name of the license steward (except to note that | ||||
| such modified license differs from this License). | ||||
|  | ||||
| 10.4. Distributing Source Code Form that is Incompatible With Secondary | ||||
| Licenses | ||||
|  | ||||
| If You choose to distribute Source Code Form that is Incompatible With | ||||
| Secondary Licenses under the terms of this version of the License, the | ||||
| notice described in Exhibit B of this License must be attached. | ||||
|  | ||||
| Exhibit A - Source Code Form License Notice | ||||
| ------------------------------------------- | ||||
|  | ||||
|   This Source Code Form is subject to the terms of the Mozilla Public | ||||
|   License, v. 2.0. If a copy of the MPL was not distributed with this | ||||
|   file, You can obtain one at http://mozilla.org/MPL/2.0/. | ||||
|  | ||||
| If it is not possible or desirable to put the notice in a particular | ||||
| file, then You may include the notice in a location (such as a LICENSE | ||||
| file in a relevant directory) where a recipient would be likely to look | ||||
| for such a notice. | ||||
|  | ||||
| You may add additional accurate notices of copyright ownership. | ||||
|  | ||||
| Exhibit B - "Incompatible With Secondary Licenses" Notice | ||||
| --------------------------------------------------------- | ||||
|  | ||||
|   This Source Code Form is "Incompatible With Secondary Licenses", as | ||||
|   defined by the Mozilla Public License, v. 2.0. | ||||
							
								
								
									
										6
									
								
								externals/CMakeLists.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								externals/CMakeLists.txt
									
									
									
									
										vendored
									
									
								
							| @@ -147,3 +147,9 @@ endif() | ||||
|  | ||||
| add_library(stb stb/stb_dxt.cpp) | ||||
| target_include_directories(stb PUBLIC ./stb) | ||||
|  | ||||
| if (ANDROID) | ||||
|    if (ARCHITECTURE_arm64) | ||||
|        add_subdirectory(libadrenotools) | ||||
|    endif() | ||||
| endif() | ||||
|   | ||||
							
								
								
									
										62
									
								
								externals/ffmpeg/CMakeLists.txt
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										62
									
								
								externals/ffmpeg/CMakeLists.txt
									
									
									
									
										vendored
									
									
								
							| @@ -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) | ||||
|     if (NOT ANDROID) | ||||
|         pkg_check_modules(LIBVA libva) | ||||
|         pkg_check_modules(CUDA cuda) | ||||
|         pkg_check_modules(FFNVCODEC ffnvcodec) | ||||
|         pkg_check_modules(VDPAU vdpau) | ||||
|     endif() | ||||
|  | ||||
|     set(FFmpeg_HWACCEL_LIBRARIES) | ||||
|     set(FFmpeg_HWACCEL_FLAGS) | ||||
| @@ -121,6 +123,26 @@ if (NOT WIN32) | ||||
|         list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau) | ||||
|     endif() | ||||
|  | ||||
|     find_program(BASH_PROGRAM bash REQUIRED) | ||||
|  | ||||
|     set(FFmpeg_CROSS_COMPILE_FLAGS "") | ||||
|     if (ANDROID) | ||||
|         string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME) | ||||
|         set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}") | ||||
|         set(SYSROOT "${TOOLCHAIN}/sysroot") | ||||
|         set(FFmpeg_CPU "armv8-a") | ||||
|         list(APPEND FFmpeg_CROSS_COMPILE_FLAGS | ||||
|             --arch=arm64 | ||||
|             #--cpu=${FFmpeg_CPU} | ||||
|             --enable-cross-compile | ||||
|             --cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android- | ||||
|             --sysroot=${SYSROOT} | ||||
|             --target-os=android | ||||
|             --extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld"  | ||||
|             --extra-ldflags="-nostdlib" | ||||
|         ) | ||||
|     endif() | ||||
|  | ||||
|     # `configure` parameters builds only exactly what yuzu needs from FFmpeg | ||||
|     # `--disable-vdpau` is needed to avoid linking issues | ||||
|     set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER}  ${CMAKE_C_COMPILER}) | ||||
| @@ -129,7 +151,7 @@ if (NOT WIN32) | ||||
|         OUTPUT | ||||
|             ${FFmpeg_MAKEFILE} | ||||
|         COMMAND | ||||
|             /bin/bash ${FFmpeg_PREFIX}/configure | ||||
|             ${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure | ||||
|                 --disable-avdevice | ||||
|                 --disable-avformat | ||||
|                 --disable-doc | ||||
| @@ -146,12 +168,14 @@ if (NOT WIN32) | ||||
|                 --cc="${FFmpeg_CC}" | ||||
|                 --cxx="${FFmpeg_CXX}" | ||||
|                 ${FFmpeg_HWACCEL_FLAGS} | ||||
|                 ${FFmpeg_CROSS_COMPILE_FLAGS} | ||||
|         WORKING_DIRECTORY | ||||
|             ${FFmpeg_BUILD_DIR} | ||||
|     ) | ||||
|     unset(FFmpeg_CC) | ||||
|     unset(FFmpeg_CXX) | ||||
|     unset(FFmpeg_HWACCEL_FLAGS) | ||||
|     unset(FFmpeg_CROSS_COMPILE_FLAGS) | ||||
|  | ||||
|     # Workaround for Ubuntu 18.04's older version of make not being able to call make as a child | ||||
|     # with context of the jobserver. Also helps ninja users. | ||||
| @@ -197,7 +221,38 @@ if (NOT WIN32) | ||||
|     else() | ||||
|         message(FATAL_ERROR "FFmpeg not found") | ||||
|     endif() | ||||
| else(WIN32) | ||||
| elseif(ANDROID) | ||||
|     # Use yuzu FFmpeg binaries | ||||
|     if (ARCHITECTURE_arm64) | ||||
|         set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-aarch64") | ||||
|     elseif (ARCHITECTURE_x86_64) | ||||
|         set(FFmpeg_EXT_NAME "ffmpeg-android-v5.1.LTS-x86_64") | ||||
|     else() | ||||
|         message(FATAL_ERROR "Unsupported architecture for Android FFmpeg") | ||||
|     endif() | ||||
|     set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") | ||||
|     download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "") | ||||
|     set(FFmpeg_FOUND YES) | ||||
|     set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) | ||||
|     set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE) | ||||
|     set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) | ||||
|     set(FFmpeg_LIBRARIES | ||||
|         ${FFmpeg_LIBRARY_DIR}/libavcodec.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libavdevice.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libavfilter.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libavformat.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libavutil.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libswresample.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libswscale.so | ||||
|         ${FFmpeg_LIBRARY_DIR}/libvpx.a | ||||
|         ${FFmpeg_LIBRARY_DIR}/libx264.a | ||||
|         CACHE PATH "Paths to FFmpeg libraries" FORCE) | ||||
|     # exported variables | ||||
|     set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE) | ||||
|     set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE) | ||||
|     set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE) | ||||
|     set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE) | ||||
| elseif(WIN32) | ||||
|     # Use yuzu FFmpeg binaries | ||||
|     set(FFmpeg_EXT_NAME "ffmpeg-5.1.3") | ||||
|     set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") | ||||
| @@ -206,7 +261,6 @@ else(WIN32) | ||||
|     set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) | ||||
|     set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE) | ||||
|     set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) | ||||
|     set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE) | ||||
|     set(FFmpeg_LIBRARIES | ||||
|     ${FFmpeg_LIBRARY_DIR}/swscale.lib | ||||
|     ${FFmpeg_LIBRARY_DIR}/avcodec.lib | ||||
|   | ||||
							
								
								
									
										1
									
								
								externals/libadrenotools
									
									
									
									
										vendored
									
									
										Submodule
									
								
							
							
								
								
								
								
								
							
						
						
									
										1
									
								
								externals/libadrenotools
									
									
									
									
										vendored
									
									
										Submodule
									
								
							 Submodule externals/libadrenotools added at 5cd3f5c5ce
									
								
							| @@ -195,3 +195,8 @@ endif() | ||||
| if (ENABLE_WEB_SERVICE) | ||||
|     add_subdirectory(web_service) | ||||
| endif() | ||||
|  | ||||
| if (ANDROID) | ||||
|     add_subdirectory(android/app/src/main/jni) | ||||
|     target_include_directories(yuzu-android PRIVATE android/app/src/main) | ||||
| endif() | ||||
|   | ||||
							
								
								
									
										65
									
								
								src/android/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/android/.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| # SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| # Built application files | ||||
| *.apk | ||||
| *.ap_ | ||||
|  | ||||
| # Files for the ART/Dalvik VM | ||||
| *.dex | ||||
|  | ||||
| # Java class files | ||||
| *.class | ||||
|  | ||||
| # Generated files | ||||
| bin/ | ||||
| gen/ | ||||
| out/ | ||||
|  | ||||
| # Gradle files | ||||
| .gradle/ | ||||
| build/ | ||||
|  | ||||
| # Local configuration file (sdk path, etc) | ||||
| local.properties | ||||
|  | ||||
| # Proguard folder generated by Eclipse | ||||
| proguard/ | ||||
|  | ||||
| # Log Files | ||||
| *.log | ||||
|  | ||||
| # Android Studio Navigation editor temp files | ||||
| .navigation/ | ||||
|  | ||||
| # Android Studio captures folder | ||||
| captures/ | ||||
|  | ||||
| # IntelliJ | ||||
| *.iml | ||||
| .idea/ | ||||
|  | ||||
| # Keystore files | ||||
| # Uncomment the following line if you do not want to check your keystore files in. | ||||
| #*.jks | ||||
|  | ||||
| # External native build folder generated in Android Studio 2.2 and later | ||||
| .externalNativeBuild | ||||
|  | ||||
| # CXX compile cache | ||||
| app/.cxx | ||||
|  | ||||
| # Google Services (e.g. APIs or Firebase) | ||||
| google-services.json | ||||
|  | ||||
| # Freeline | ||||
| freeline.py | ||||
| freeline/ | ||||
| freeline_project_description.json | ||||
|  | ||||
| # fastlane | ||||
| fastlane/report.xml | ||||
| fastlane/Preview.html | ||||
| fastlane/screenshots | ||||
| fastlane/test_output | ||||
| fastlane/readme.md | ||||
							
								
								
									
										248
									
								
								src/android/app/build.gradle.kts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								src/android/app/build.gradle.kts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,248 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
|  | ||||
| plugins { | ||||
|     id("com.android.application") | ||||
|     id("org.jetbrains.kotlin.android") | ||||
|     id("kotlin-parcelize") | ||||
|     kotlin("plugin.serialization") version "1.8.21" | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Use the number of seconds/10 since Jan 1 2016 as the versionCode. | ||||
|  * This lets us upload a new build at most every 10 seconds for the | ||||
|  * next 680 years. | ||||
|  */ | ||||
| val autoVersion = (((System.currentTimeMillis() / 1000) - 1451606400) / 10).toInt() | ||||
|  | ||||
| @Suppress("UnstableApiUsage") | ||||
| android { | ||||
|     namespace = "org.yuzu.yuzu_emu" | ||||
|  | ||||
|     compileSdkVersion = "android-33" | ||||
|     ndkVersion = "25.2.9519653" | ||||
|  | ||||
|     buildFeatures { | ||||
|         viewBinding = true | ||||
|     } | ||||
|  | ||||
|     compileOptions { | ||||
|         sourceCompatibility = JavaVersion.VERSION_17 | ||||
|         targetCompatibility = JavaVersion.VERSION_17 | ||||
|     } | ||||
|  | ||||
|     kotlinOptions { | ||||
|         jvmTarget = "17" | ||||
|     } | ||||
|  | ||||
|     packaging { | ||||
|         // This is necessary for libadrenotools custom driver loading | ||||
|         jniLibs.useLegacyPackaging = true | ||||
|     } | ||||
|  | ||||
|     lint { | ||||
|         // This is important as it will run lint but not abort on error | ||||
|         // Lint has some overly obnoxious "errors" that should really be warnings | ||||
|         abortOnError = false | ||||
|  | ||||
|         //Uncomment disable lines for test builds... | ||||
|         //disable 'MissingTranslation'bin | ||||
|         //disable 'ExtraTranslation' | ||||
|     } | ||||
|  | ||||
|     defaultConfig { | ||||
|         // TODO If this is ever modified, change application_id in strings.xml | ||||
|         applicationId = "org.yuzu.yuzu_emu" | ||||
|         minSdk = 30 | ||||
|         targetSdk = 33 | ||||
|         versionName = getGitVersion() | ||||
|  | ||||
|         ndk { | ||||
|             @SuppressLint("ChromeOsAbiSupport") | ||||
|             abiFilters += listOf("arm64-v8a") | ||||
|         } | ||||
|  | ||||
|         buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"") | ||||
|         buildConfigField("String", "BRANCH", "\"${getBranch()}\"") | ||||
|     } | ||||
|  | ||||
|     // Define build types, which are orthogonal to product flavors. | ||||
|     buildTypes { | ||||
|  | ||||
|         // Signed by release key, allowing for upload to Play Store. | ||||
|         release { | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|             isMinifyEnabled = true | ||||
|             isDebuggable = false | ||||
|             proguardFiles( | ||||
|                 getDefaultProguardFile("proguard-android.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         register("relWithVersionCode") { | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|             isMinifyEnabled = true | ||||
|             isDebuggable = false | ||||
|             proguardFiles( | ||||
|                 getDefaultProguardFile("proguard-android.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         // builds a release build that doesn't need signing | ||||
|         // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. | ||||
|         register("relWithDebInfo") { | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|             isMinifyEnabled = true | ||||
|             isDebuggable = true | ||||
|             proguardFiles( | ||||
|                 getDefaultProguardFile("proguard-android.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|             versionNameSuffix = "-debug" | ||||
|             isJniDebuggable = true | ||||
|         } | ||||
|  | ||||
|         // Signed by debug key disallowing distribution on Play Store. | ||||
|         // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. | ||||
|         debug { | ||||
|             isDebuggable = true | ||||
|             isJniDebuggable = true | ||||
|             versionNameSuffix = "-debug" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     flavorDimensions.add("version") | ||||
|     productFlavors { | ||||
|         create("mainline") { | ||||
|             dimension = "version" | ||||
|             buildConfigField("Boolean", "PREMIUM", "false") | ||||
|         } | ||||
|  | ||||
|         create("ea") { | ||||
|             dimension = "version" | ||||
|             buildConfigField("Boolean", "PREMIUM", "true") | ||||
|             applicationIdSuffix = ".ea" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     externalNativeBuild { | ||||
|         cmake { | ||||
|             version = "3.22.1" | ||||
|             path = file("../../../CMakeLists.txt") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     defaultConfig { | ||||
|         externalNativeBuild { | ||||
|             cmake { | ||||
|                 arguments( | ||||
|                     "-DENABLE_QT=0", // Don't use QT | ||||
|                     "-DENABLE_SDL2=0", // Don't use SDL | ||||
|                     "-DENABLE_WEB_SERVICE=0", // Don't use telemetry | ||||
|                     "-DBUNDLE_SPEEX=ON", | ||||
|                     "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work | ||||
|                     "-DYUZU_USE_BUNDLED_VCPKG=ON", | ||||
|                     "-DYUZU_USE_BUNDLED_FFMPEG=ON", | ||||
|                     "-DYUZU_ENABLE_LTO=ON" | ||||
|                 ) | ||||
|  | ||||
|                 abiFilters("arm64-v8a", "x86_64") | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| dependencies { | ||||
|     implementation("androidx.core:core-ktx:1.10.1") | ||||
|     implementation("androidx.appcompat:appcompat:1.6.1") | ||||
|     implementation("androidx.recyclerview:recyclerview:1.3.0") | ||||
|     implementation("androidx.constraintlayout:constraintlayout:2.1.4") | ||||
|     implementation("androidx.fragment:fragment-ktx:1.5.7") | ||||
|     implementation("androidx.documentfile:documentfile:1.0.1") | ||||
|     implementation("com.google.android.material:material:1.9.0") | ||||
|     implementation("androidx.preference:preference:1.2.0") | ||||
|     implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") | ||||
|     implementation("io.coil-kt:coil:2.2.2") | ||||
|     implementation("androidx.core:core-splashscreen:1.0.1") | ||||
|     implementation("androidx.window:window:1.0.0") | ||||
|     implementation("org.ini4j:ini4j:0.5.4") | ||||
|     implementation("androidx.constraintlayout:constraintlayout:2.1.4") | ||||
|     implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") | ||||
|     implementation("androidx.navigation:navigation-fragment-ktx:2.5.3") | ||||
|     implementation("androidx.navigation:navigation-ui-ktx:2.5.3") | ||||
|     implementation("info.debatty:java-string-similarity:2.0.0") | ||||
|     implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0") | ||||
| } | ||||
|  | ||||
| fun getGitVersion(): String { | ||||
|     var versionName = "0.0" | ||||
|  | ||||
|     try { | ||||
|         versionName = ProcessBuilder("git", "describe", "--always", "--long") | ||||
|             .directory(project.rootDir) | ||||
|             .redirectOutput(ProcessBuilder.Redirect.PIPE) | ||||
|             .redirectError(ProcessBuilder.Redirect.PIPE) | ||||
|             .start().inputStream.bufferedReader().use { it.readText() } | ||||
|             .trim() | ||||
|             .replace(Regex("(-0)?-[^-]+$"), "") | ||||
|     } catch (e: Exception) { | ||||
|         logger.error("Cannot find git, defaulting to dummy version number") | ||||
|     } | ||||
|  | ||||
|     if (System.getenv("GITHUB_ACTIONS") != null) { | ||||
|         val gitTag = System.getenv("GIT_TAG_NAME") | ||||
|         versionName = gitTag ?: versionName | ||||
|     } | ||||
|  | ||||
|     return versionName | ||||
| } | ||||
|  | ||||
| fun getGitHash(): String { | ||||
|     try { | ||||
|         val processBuilder = ProcessBuilder("git", "rev-parse", "--short", "HEAD") | ||||
|         processBuilder.directory(project.rootDir) | ||||
|         val process = processBuilder.start() | ||||
|         val inputStream = process.inputStream | ||||
|         val errorStream = process.errorStream | ||||
|         process.waitFor() | ||||
|  | ||||
|         return if (process.exitValue() == 0) { | ||||
|             inputStream.bufferedReader() | ||||
|                 .use { it.readText().trim() } // return the value of gitHash | ||||
|         } else { | ||||
|             val errorMessage = errorStream.bufferedReader().use { it.readText().trim() } | ||||
|             logger.error("Error running git command: $errorMessage") | ||||
|             "dummy-hash" // return a dummy hash value in case of an error | ||||
|         } | ||||
|     } catch (e: Exception) { | ||||
|         logger.error("$e: Cannot find git, defaulting to dummy build hash") | ||||
|         return "dummy-hash" // return a dummy hash value in case of an error | ||||
|     } | ||||
| } | ||||
|  | ||||
| fun getBranch(): String { | ||||
|     try { | ||||
|         val processBuilder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD") | ||||
|         processBuilder.directory(project.rootDir) | ||||
|         val process = processBuilder.start() | ||||
|         val inputStream = process.inputStream | ||||
|         val errorStream = process.errorStream | ||||
|         process.waitFor() | ||||
|  | ||||
|         return if (process.exitValue() == 0) { | ||||
|             inputStream.bufferedReader() | ||||
|                 .use { it.readText().trim() } // return the value of gitHash | ||||
|         } else { | ||||
|             val errorMessage = errorStream.bufferedReader().use { it.readText().trim() } | ||||
|             logger.error("Error running git command: $errorMessage") | ||||
|             "dummy-hash" // return a dummy hash value in case of an error | ||||
|         } | ||||
|     } catch (e: Exception) { | ||||
|         logger.error("$e: Cannot find git, defaulting to dummy build hash") | ||||
|         return "dummy-hash" // return a dummy hash value in case of an error | ||||
|     } | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/android/app/proguard-rules.pro
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| # SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| # To get usable stack traces | ||||
| -dontobfuscate | ||||
|  | ||||
| # Prevents crashing when using Wini | ||||
| -keep class org.ini4j.spi.IniParser | ||||
| -keep class org.ini4j.spi.IniBuilder | ||||
| -keep class org.ini4j.spi.IniFormatter | ||||
|  | ||||
| # Suppress warnings for R8 | ||||
| -dontwarn org.bouncycastle.jsse.BCSSLParameters | ||||
| -dontwarn org.bouncycastle.jsse.BCSSLSocket | ||||
| -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider | ||||
| -dontwarn org.conscrypt.Conscrypt$Version | ||||
| -dontwarn org.conscrypt.Conscrypt | ||||
| -dontwarn org.conscrypt.ConscryptHostnameVerifier | ||||
| -dontwarn org.openjsse.javax.net.ssl.SSLParameters | ||||
| -dontwarn org.openjsse.javax.net.ssl.SSLSocket | ||||
| -dontwarn org.openjsse.net.ssl.OpenJSSE | ||||
| -dontwarn java.beans.Introspector | ||||
| -dontwarn java.beans.VetoableChangeListener | ||||
| -dontwarn java.beans.VetoableChangeSupport | ||||
							
								
								
									
										22
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="200dp" | ||||
|     android:height="200dp" | ||||
|     android:viewportWidth="500" | ||||
|     android:viewportHeight="500"> | ||||
|     <path | ||||
|         android:fillColor="#C6C6C6" | ||||
|         android:fillType="nonZero" | ||||
|         android:pathData="M262.66,175.11L262.66,375.05C318.54,375.05 363.85,330.29 363.85,275.08C363.85,219.87 318.54,175.11 262.66,175.11M282.43,197.01C318.67,206 344.09,238.19 344.09,275.11C344.09,312.03 318.67,344.22 282.43,353.2L282.43,197.01" | ||||
|         android:strokeWidth="1.46" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeLineCap="butt" | ||||
|         android:strokeLineJoin="miter" /> | ||||
|     <path | ||||
|         android:fillColor="#FFDC00" | ||||
|         android:fillType="nonZero" | ||||
|         android:pathData="M237.31,125.11C181.43,125.11 136.12,169.87 136.12,225.08C136.12,280.29 181.43,325.05 237.31,325.05ZM217.57,147.01L217.57,303.2C189.11,296.16 166.67,274.54 158.84,246.6C151.01,218.65 159,188.71 179.75,168.21C190.16,157.86 203.24,150.53 217.57,147.01" | ||||
|         android:strokeWidth="1.46" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeLineCap="butt" | ||||
|         android:strokeLineJoin="miter" /> | ||||
| </vector> | ||||
							
								
								
									
										12
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="155.3dp" | ||||
|     android:height="172.55dp" | ||||
|     android:viewportWidth="155.3" | ||||
|     android:viewportHeight="172.55"> | ||||
|     <path | ||||
|         android:fillColor="#C6C6C6" | ||||
|         android:pathData="M86.28,34.51v138a69,69 0,0 0,0 -138M99.76,49.63a55.57,55.57 0,0 1,0 107.8V49.63" /> | ||||
|     <path | ||||
|         android:fillColor="#FFDC00" | ||||
|         android:pathData="M69,0a69,69 0,0 0,0 138ZM55.54,15.12v107.8A55.55,55.55 0,0 1,29.75 29.75,55.1 55.1,0 0,1 55.54,15.12" /> | ||||
| </vector> | ||||
							
								
								
									
										24
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     android:width="340.97dp" | ||||
|     android:height="389.85dp" | ||||
|     android:viewportWidth="340.97" | ||||
|     android:viewportHeight="389.85"> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorOnSurface" | ||||
|         android:pathData="M341,268.68v73c0,14.5 -2.24,25.24 -6.83,32.82 -5.92,10.15 -16.21,15.32 -30.54,15.32S279,384.61 273,374.27c-4.56,-7.64 -6.8,-18.42 -6.8,-32.92V268.68a4.52,4.52 0,0 1,4.51 -4.51H273a4.5,4.5 0,0 1,4.5 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.52,4.52 0,0 1,4.52 -4.51h2.27A4.5,4.5 0,0 1,341 268.68Z" /> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorOnSurface" | ||||
|         android:pathData="M246.49,389.85H178.6c-2.35,0 -4.72,-1.88 -4.72,-6.08a8.28,8.28 0,0 1,1.33 -4.48l60.33,-104.47H186a4.51,4.51 0,0 1,-4.51 -4.51v-1.58a4.51,4.51 0,0 1,4.48 -4.51h0.8c58.69,-0.11 59.12,0 59.67,0.07a5.19,5.19 0,0 1,4 5.8,8.69 8.69,0 0,1 -1.33,3.76l-60.6,104.77h58a4.51,4.51 0,0 1,4.51 4.51v2.21A4.51,4.51 0,0 1,246.49 389.85Z" /> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorOnSurface" | ||||
|         android:pathData="M73.6,268.68v82.06c0,26 -11.8,38.44 -37.12,39.09h-0.12a4.51,4.51 0,0 1,-4.51 -4.51V383a4.51,4.51 0,0 1,4.48 -4.5c18.49,-0.15 26,-8.23 26,-27.9v-2.37A32.34,32.34 0,0 1,59 351.46c-6.39,5.5 -14.5,8.29 -24.07,8.29C12.09,359.75 0,347.34 0,323.86V268.68a4.52,4.52 0,0 1,4.51 -4.51H6.73a4.52,4.52 0,0 1,4.5 4.51v55c0,7.6 1.82,14.22 5,18.18 3.57,4.56 9.17,6.49 18.75,6.49 10.13,0 17.32,-3.76 22,-11.5 3.61,-5.92 5.43,-13.66 5.43,-23V268.68a4.52,4.52 0,0 1,4.51 -4.51h2.22A4.52,4.52 0,0 1,73.6 268.68Z" /> | ||||
|     <path | ||||
|         android:fillColor="?attr/colorOnSurface" | ||||
|         android:pathData="M163.27,268.68v73c0,14.5 -2.24,25.24 -6.84,32.82 -5.92,10.15 -16.2,15.32 -30.53,15.32s-24.62,-5.23 -30.58,-15.57c-4.56,-7.64 -6.79,-18.42 -6.79,-32.92V268.68A4.51,4.51 0,0 1,93 264.17h2.28a4.51,4.51 0,0 1,4.51 4.51v72.5c0,33.53 14.88,37.4 26.07,37.4 12.14,0 26.08,-4.17 26.08,-36.71V268.68a4.51,4.51 0,0 1,4.51 -4.51h2.27A4.51,4.51 0,0 1,163.27 268.68Z" /> | ||||
|     <path | ||||
|         android:fillColor="#C6C6C6" | ||||
|         android:pathData="M181.2,42.83V214.17a85.67,85.67 0,0 0,0 -171.34M197.93,61.6a69,69 0,0 1,0 133.8V61.6" /> | ||||
|     <path | ||||
|         android:fillColor="#FFDC00" | ||||
|         android:pathData="M159.78,0a85.67,85.67 0,1 0,0 171.33ZM143.05,18.77v133.8A69,69 0,0 1,111 36.92a68.47,68.47 0,0 1,32 -18.15" /> | ||||
| </vector> | ||||
							
								
								
									
										91
									
								
								src/android/app/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/android/app/src/main/AndroidManifest.xml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
|  | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| SPDX-License-Identifier: GPL-3.0-or-later | ||||
| --> | ||||
|  | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <uses-feature | ||||
|         android:name="android.hardware.touchscreen" | ||||
|         android:required="false"/> | ||||
|     <uses-feature | ||||
|         android:name="android.hardware.gamepad" | ||||
|         android:required="false"/> | ||||
|  | ||||
|     <uses-feature | ||||
|         android:name="android.hardware.vulkan.version" | ||||
|         android:version="0x401000" | ||||
|         android:required="true" /> | ||||
|  | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||
|     <uses-permission android:name="android.permission.NFC" /> | ||||
|     <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> | ||||
|  | ||||
|     <application | ||||
|         android:name="org.yuzu.yuzu_emu.YuzuApplication" | ||||
|         android:label="@string/app_name" | ||||
|         android:icon="@drawable/ic_launcher" | ||||
|         android:allowBackup="true" | ||||
|         android:hasFragileUserData="true" | ||||
|         android:supportsRtl="true" | ||||
|         android:isGame="true" | ||||
|         android:banner="@drawable/ic_launcher" | ||||
|         android:extractNativeLibs="true" | ||||
|         android:fullBackupContent="@xml/data_extraction_rules" | ||||
|         android:dataExtractionRules="@xml/data_extraction_rules_api_31" | ||||
|         android:enableOnBackInvokedCallback="true"> | ||||
|  | ||||
|         <activity | ||||
|             android:name="org.yuzu.yuzu_emu.ui.main.MainActivity" | ||||
|             android:exported="true" | ||||
|             android:theme="@style/Theme.Yuzu.Splash.Main"> | ||||
|  | ||||
|             <!-- This intentfilter marks this Activity as the one that gets launched from Home screen. --> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN"/> | ||||
|  | ||||
|                 <category android:name="android.intent.category.LAUNCHER"/> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <activity | ||||
|             android:name="org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity" | ||||
|             android:theme="@style/Theme.Yuzu.Main" | ||||
|             android:label="@string/preferences_settings"/> | ||||
|  | ||||
|         <activity | ||||
|             android:name="org.yuzu.yuzu_emu.activities.EmulationActivity" | ||||
|             android:theme="@style/Theme.Yuzu.Main" | ||||
|             android:launchMode="singleTop" | ||||
|             android:screenOrientation="userLandscape" | ||||
|             android:exported="true"> | ||||
|  | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.nfc.action.TECH_DISCOVERED" /> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <data android:mimeType="application/octet-stream" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|             <meta-data | ||||
|                 android:name="android.nfc.action.TECH_DISCOVERED" | ||||
|                 android:resource="@xml/nfc_tech_filter" /> | ||||
|         </activity> | ||||
|  | ||||
|         <service android:name="org.yuzu.yuzu_emu.utils.ForegroundService"/> | ||||
|  | ||||
|         <provider | ||||
|             android:name=".features.DocumentProvider" | ||||
|             android:authorities="${applicationId}.user" | ||||
|             android:grantUriPermissions="true" | ||||
|             android:exported="true" | ||||
|             android:permission="android.permission.MANAGE_DOCUMENTS"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.content.action.DOCUMENTS_PROVIDER" /> | ||||
|             </intent-filter> | ||||
|         </provider> | ||||
|  | ||||
|     </application> | ||||
|  | ||||
| </manifest> | ||||
							
								
								
									
										508
									
								
								src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										508
									
								
								src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,508 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.DialogInterface | ||||
| import android.os.Bundle | ||||
| import android.text.Html | ||||
| import android.text.method.LinkMovementMethod | ||||
| import android.view.Surface | ||||
| import android.view.View | ||||
| import android.widget.TextView | ||||
| import androidx.annotation.Keep | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext | ||||
| import org.yuzu.yuzu_emu.activities.EmulationActivity | ||||
| import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath | ||||
| import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize | ||||
| import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri | ||||
| import org.yuzu.yuzu_emu.utils.Log.error | ||||
| import org.yuzu.yuzu_emu.utils.Log.verbose | ||||
| import org.yuzu.yuzu_emu.utils.Log.warning | ||||
| import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable | ||||
| import java.lang.ref.WeakReference | ||||
|  | ||||
| /** | ||||
|  * Class which contains methods that interact | ||||
|  * with the native side of the Yuzu code. | ||||
|  */ | ||||
| object NativeLibrary { | ||||
|     /** | ||||
|      * Default controller id for each device | ||||
|      */ | ||||
|     const val Player1Device = 0 | ||||
|     const val Player2Device = 1 | ||||
|     const val Player3Device = 2 | ||||
|     const val Player4Device = 3 | ||||
|     const val Player5Device = 4 | ||||
|     const val Player6Device = 5 | ||||
|     const val Player7Device = 6 | ||||
|     const val Player8Device = 7 | ||||
|     const val ConsoleDevice = 8 | ||||
|  | ||||
|     /** | ||||
|      * Controller type for each device | ||||
|      */ | ||||
|     const val ProController = 3 | ||||
|     const val Handheld = 4 | ||||
|     const val JoyconDual = 5 | ||||
|     const val JoyconLeft = 6 | ||||
|     const val JoyconRight = 7 | ||||
|     const val GameCube = 8 | ||||
|     const val Pokeball = 9 | ||||
|     const val NES = 10 | ||||
|     const val SNES = 11 | ||||
|     const val N64 = 12 | ||||
|     const val SegaGenesis = 13 | ||||
|  | ||||
|     @JvmField | ||||
|     var sEmulationActivity = WeakReference<EmulationActivity?>(null) | ||||
|  | ||||
|     init { | ||||
|         try { | ||||
|             System.loadLibrary("yuzu-android") | ||||
|         } catch (ex: UnsatisfiedLinkError) { | ||||
|             error("[NativeLibrary] $ex") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Keep | ||||
|     @JvmStatic | ||||
|     fun openContentUri(path: String?, openmode: String?): Int { | ||||
|         return if (isNativePath(path!!)) { | ||||
|             YuzuApplication.documentsTree!!.openContentUri(path, openmode) | ||||
|         } else openContentUri(appContext, path, openmode) | ||||
|     } | ||||
|  | ||||
|     @Keep | ||||
|     @JvmStatic | ||||
|     fun getSize(path: String?): Long { | ||||
|         return if (isNativePath(path!!)) { | ||||
|             YuzuApplication.documentsTree!!.getFileSize(path) | ||||
|         } else getFileSize(appContext, path) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns true if pro controller isn't available and handheld is | ||||
|      */ | ||||
|     external fun isHandheldOnly(): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Changes controller type for a specific device. | ||||
|      * | ||||
|      * @param Device The input descriptor of the gamepad. | ||||
|      * @param Type The NpadStyleIndex of the gamepad. | ||||
|      */ | ||||
|     external fun setDeviceType(Device: Int, Type: Int): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles event when a gamepad is connected. | ||||
|      * | ||||
|      * @param Device The input descriptor of the gamepad. | ||||
|      */ | ||||
|     external fun onGamePadConnectEvent(Device: Int): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles event when a gamepad is disconnected. | ||||
|      * | ||||
|      * @param Device The input descriptor of the gamepad. | ||||
|      */ | ||||
|     external fun onGamePadDisconnectEvent(Device: Int): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles button press events for a gamepad. | ||||
|      * | ||||
|      * @param Device The input descriptor of the gamepad. | ||||
|      * @param Button Key code identifying which button was pressed. | ||||
|      * @param Action Mask identifying which action is happening (button pressed down, or button released). | ||||
|      * @return If we handled the button press. | ||||
|      */ | ||||
|     external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles joystick movement events. | ||||
|      * | ||||
|      * @param Device The device ID of the gamepad. | ||||
|      * @param Axis   The axis ID | ||||
|      * @param x_axis The value of the x-axis represented by the given ID. | ||||
|      * @param y_axis The value of the y-axis represented by the given ID. | ||||
|      */ | ||||
|     external fun onGamePadJoystickEvent( | ||||
|         Device: Int, | ||||
|         Axis: Int, | ||||
|         x_axis: Float, | ||||
|         y_axis: Float | ||||
|     ): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles motion events. | ||||
|      * | ||||
|      * @param delta_timestamp         The finger id corresponding to this event | ||||
|      * @param gyro_x,gyro_y,gyro_z    The value of the accelerometer sensor. | ||||
|      * @param accel_x,accel_y,accel_z The value of the y-axis | ||||
|      */ | ||||
|     external fun onGamePadMotionEvent( | ||||
|         Device: Int, | ||||
|         delta_timestamp: Long, | ||||
|         gyro_x: Float, | ||||
|         gyro_y: Float, | ||||
|         gyro_z: Float, | ||||
|         accel_x: Float, | ||||
|         accel_y: Float, | ||||
|         accel_z: Float | ||||
|     ): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Signals and load a nfc tag | ||||
|      * | ||||
|      * @param data         Byte array containing all the data from a nfc tag | ||||
|      */ | ||||
|     external fun onReadNfcTag(data: ByteArray?): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Removes current loaded nfc tag | ||||
|      */ | ||||
|     external fun onRemoveNfcTag(): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Handles touch press events. | ||||
|      * | ||||
|      * @param finger_id The finger id corresponding to this event | ||||
|      * @param x_axis    The value of the x-axis. | ||||
|      * @param y_axis    The value of the y-axis. | ||||
|      */ | ||||
|     external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float) | ||||
|  | ||||
|     /** | ||||
|      * Handles touch movement. | ||||
|      * | ||||
|      * @param x_axis The value of the instantaneous x-axis. | ||||
|      * @param y_axis The value of the instantaneous y-axis. | ||||
|      */ | ||||
|     external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float) | ||||
|  | ||||
|     /** | ||||
|      * Handles touch release events. | ||||
|      * | ||||
|      * @param finger_id The finger id corresponding to this event | ||||
|      */ | ||||
|     external fun onTouchReleased(finger_id: Int) | ||||
|  | ||||
|     external fun reloadSettings() | ||||
|  | ||||
|     external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String? | ||||
|  | ||||
|     external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?) | ||||
|  | ||||
|     external fun initGameIni(gameID: String?) | ||||
|  | ||||
|     /** | ||||
|      * Gets the embedded icon within the given ROM. | ||||
|      * | ||||
|      * @param filename the file path to the ROM. | ||||
|      * @return a byte array containing the JPEG data for the icon. | ||||
|      */ | ||||
|     external fun getIcon(filename: String): ByteArray | ||||
|  | ||||
|     /** | ||||
|      * Gets the embedded title of the given ISO/ROM. | ||||
|      * | ||||
|      * @param filename The file path to the ISO/ROM. | ||||
|      * @return the embedded title of the ISO/ROM. | ||||
|      */ | ||||
|     external fun getTitle(filename: String): String | ||||
|  | ||||
|     external fun getDescription(filename: String): String | ||||
|  | ||||
|     external fun getGameId(filename: String): String | ||||
|  | ||||
|     external fun getRegions(filename: String): String | ||||
|  | ||||
|     external fun getCompany(filename: String): String | ||||
|  | ||||
|     external fun setAppDirectory(directory: String) | ||||
|  | ||||
|     external fun initializeGpuDriver( | ||||
|         hookLibDir: String?, | ||||
|         customDriverDir: String?, | ||||
|         customDriverName: String?, | ||||
|         fileRedirectDir: String? | ||||
|     ) | ||||
|  | ||||
|     external fun reloadKeys(): Boolean | ||||
|  | ||||
|     external fun initializeEmulation() | ||||
|  | ||||
|     external fun defaultCPUCore(): Int | ||||
|  | ||||
|     /** | ||||
|      * Begins emulation. | ||||
|      */ | ||||
|     external fun run(path: String?) | ||||
|  | ||||
|     /** | ||||
|      * Begins emulation from the specified savestate. | ||||
|      */ | ||||
|     external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean) | ||||
|  | ||||
|     // Surface Handling | ||||
|     external fun surfaceChanged(surf: Surface?) | ||||
|  | ||||
|     external fun surfaceDestroyed() | ||||
|  | ||||
|     /** | ||||
|      * Unpauses emulation from a paused state. | ||||
|      */ | ||||
|     external fun unPauseEmulation() | ||||
|  | ||||
|     /** | ||||
|      * Pauses emulation. | ||||
|      */ | ||||
|     external fun pauseEmulation() | ||||
|  | ||||
|     /** | ||||
|      * Stops emulation. | ||||
|      */ | ||||
|     external fun stopEmulation() | ||||
|  | ||||
|     /** | ||||
|      * Resets the in-memory ROM metadata cache. | ||||
|      */ | ||||
|     external fun resetRomMetadata() | ||||
|  | ||||
|     /** | ||||
|      * Returns true if emulation is running (or is paused). | ||||
|      */ | ||||
|     external fun isRunning(): Boolean | ||||
|  | ||||
|     /** | ||||
|      * Returns the performance stats for the current game | ||||
|      */ | ||||
|     external fun getPerfStats(): DoubleArray | ||||
|  | ||||
|     /** | ||||
|      * Notifies the core emulation that the orientation has changed. | ||||
|      */ | ||||
|     external fun notifyOrientationChange(layout_option: Int, rotation: Int) | ||||
|  | ||||
|     enum class CoreError { | ||||
|         ErrorSystemFiles, | ||||
|         ErrorSavestate, | ||||
|         ErrorUnknown | ||||
|     } | ||||
|  | ||||
|     private var coreErrorAlertResult = false | ||||
|     private val coreErrorAlertLock = Object() | ||||
|  | ||||
|     class CoreErrorDialogFragment : DialogFragment() { | ||||
|         override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||
|             val title = requireArguments().serializable<String>("title") | ||||
|             val message = requireArguments().serializable<String>("message") | ||||
|  | ||||
|             return MaterialAlertDialogBuilder(requireActivity()) | ||||
|                 .setTitle(title) | ||||
|                 .setMessage(message) | ||||
|                 .setPositiveButton(R.string.continue_button, null) | ||||
|                 .setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int -> | ||||
|                     coreErrorAlertResult = false | ||||
|                     synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } | ||||
|                 } | ||||
|                 .create() | ||||
|         } | ||||
|  | ||||
|         override fun onDismiss(dialog: DialogInterface) { | ||||
|             coreErrorAlertResult = true | ||||
|             synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() } | ||||
|         } | ||||
|  | ||||
|         companion object { | ||||
|             fun newInstance(title: String?, message: String?): CoreErrorDialogFragment { | ||||
|                 val frag = CoreErrorDialogFragment() | ||||
|                 val args = Bundle() | ||||
|                 args.putString("title", title) | ||||
|                 args.putString("message", message) | ||||
|                 frag.arguments = args | ||||
|                 return frag | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun onCoreErrorImpl(title: String, message: String) { | ||||
|         val emulationActivity = sEmulationActivity.get() | ||||
|         if (emulationActivity == null) { | ||||
|             error("[NativeLibrary] EmulationActivity not present") | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val fragment = CoreErrorDialogFragment.newInstance(title, message) | ||||
|         fragment.show(emulationActivity.supportFragmentManager, "coreError") | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Handles a core error. | ||||
|      * | ||||
|      * @return true: continue; false: abort | ||||
|      */ | ||||
|     fun onCoreError(error: CoreError?, details: String): Boolean { | ||||
|         val emulationActivity = sEmulationActivity.get() | ||||
|         if (emulationActivity == null) { | ||||
|             error("[NativeLibrary] EmulationActivity not present") | ||||
|             return false | ||||
|         } | ||||
|  | ||||
|         val title: String | ||||
|         val message: String | ||||
|         when (error) { | ||||
|             CoreError.ErrorSystemFiles -> { | ||||
|                 title = emulationActivity.getString(R.string.system_archive_not_found) | ||||
|                 message = emulationActivity.getString( | ||||
|                     R.string.system_archive_not_found_message, | ||||
|                     details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) } | ||||
|                 ) | ||||
|             } | ||||
|             CoreError.ErrorSavestate -> { | ||||
|                 title = emulationActivity.getString(R.string.save_load_error) | ||||
|                 message = details | ||||
|             } | ||||
|             CoreError.ErrorUnknown -> { | ||||
|                 title = emulationActivity.getString(R.string.fatal_error) | ||||
|                 message = emulationActivity.getString(R.string.fatal_error_message) | ||||
|             } | ||||
|             else -> { | ||||
|                 return true | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Show the AlertDialog on the main thread. | ||||
|         emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) }) | ||||
|  | ||||
|         // Wait for the lock to notify that it is complete. | ||||
|         synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() } | ||||
|  | ||||
|         return coreErrorAlertResult | ||||
|     } | ||||
|  | ||||
|     @Keep | ||||
|     @JvmStatic | ||||
|     fun exitEmulationActivity(resultCode: Int) { | ||||
|         val Success = 0 | ||||
|         val ErrorNotInitialized = 1 | ||||
|         val ErrorGetLoader = 2 | ||||
|         val ErrorSystemFiles = 3 | ||||
|         val ErrorSharedFont = 4 | ||||
|         val ErrorVideoCore = 5 | ||||
|         val ErrorUnknown = 6 | ||||
|         val ErrorLoader = 7 | ||||
|  | ||||
|         val captionId: Int | ||||
|         var descriptionId: Int | ||||
|         when (resultCode) { | ||||
|             ErrorVideoCore -> { | ||||
|                 captionId = R.string.loader_error_video_core | ||||
|                 descriptionId = R.string.loader_error_video_core_description | ||||
|             } | ||||
|             else -> { | ||||
|                 captionId = R.string.loader_error_encrypted | ||||
|                 descriptionId = R.string.loader_error_encrypted_roms_description | ||||
|                 if (!reloadKeys()) { | ||||
|                     descriptionId = R.string.loader_error_encrypted_keys_description | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         val emulationActivity = sEmulationActivity.get() | ||||
|         if (emulationActivity == null) { | ||||
|             warning("[NativeLibrary] EmulationActivity is null, can't exit.") | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val builder = MaterialAlertDialogBuilder(emulationActivity) | ||||
|             .setTitle(captionId) | ||||
|             .setMessage( | ||||
|                 Html.fromHtml( | ||||
|                     emulationActivity.getString(descriptionId), | ||||
|                     Html.FROM_HTML_MODE_LEGACY | ||||
|                 ) | ||||
|             ) | ||||
|             .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() } | ||||
|             .setOnDismissListener { emulationActivity.finish() } | ||||
|         emulationActivity.runOnUiThread { | ||||
|             val alert = builder.create() | ||||
|             alert.show() | ||||
|             (alert.findViewById<View>(android.R.id.message) as TextView).movementMethod = | ||||
|                 LinkMovementMethod.getInstance() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun setEmulationActivity(emulationActivity: EmulationActivity?) { | ||||
|         verbose("[NativeLibrary] Registering EmulationActivity.") | ||||
|         sEmulationActivity = WeakReference(emulationActivity) | ||||
|     } | ||||
|  | ||||
|     fun clearEmulationActivity() { | ||||
|         verbose("[NativeLibrary] Unregistering EmulationActivity.") | ||||
|         sEmulationActivity.clear() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Logs the Yuzu version, Android version and, CPU. | ||||
|      */ | ||||
|     external fun logDeviceInfo() | ||||
|  | ||||
|     /** | ||||
|      * Submits inline keyboard text. Called on input for buttons that result text. | ||||
|      * @param text Text to submit to the inline software keyboard implementation. | ||||
|      */ | ||||
|     external fun submitInlineKeyboardText(text: String?) | ||||
|  | ||||
|     /** | ||||
|      * Submits inline keyboard input. Used to indicate keys pressed that are not text. | ||||
|      * @param key_code Android Key Code associated with the keyboard input. | ||||
|      */ | ||||
|     external fun submitInlineKeyboardInput(key_code: Int) | ||||
|  | ||||
|     /** | ||||
|      * Button type for use in onTouchEvent | ||||
|      */ | ||||
|     object ButtonType { | ||||
|         const val BUTTON_A = 0 | ||||
|         const val BUTTON_B = 1 | ||||
|         const val BUTTON_X = 2 | ||||
|         const val BUTTON_Y = 3 | ||||
|         const val STICK_L = 4 | ||||
|         const val STICK_R = 5 | ||||
|         const val TRIGGER_L = 6 | ||||
|         const val TRIGGER_R = 7 | ||||
|         const val TRIGGER_ZL = 8 | ||||
|         const val TRIGGER_ZR = 9 | ||||
|         const val BUTTON_PLUS = 10 | ||||
|         const val BUTTON_MINUS = 11 | ||||
|         const val DPAD_LEFT = 12 | ||||
|         const val DPAD_UP = 13 | ||||
|         const val DPAD_RIGHT = 14 | ||||
|         const val DPAD_DOWN = 15 | ||||
|         const val BUTTON_SL = 16 | ||||
|         const val BUTTON_SR = 17 | ||||
|         const val BUTTON_HOME = 18 | ||||
|         const val BUTTON_CAPTURE = 19 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Stick type for use in onTouchEvent | ||||
|      */ | ||||
|     object StickType { | ||||
|         const val STICK_L = 0 | ||||
|         const val STICK_R = 1 | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Button states | ||||
|      */ | ||||
|     object ButtonState { | ||||
|         const val RELEASED = 0 | ||||
|         const val PRESSED = 1 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,333 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.activities | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.content.res.Configuration | ||||
| import android.graphics.Rect | ||||
| import android.hardware.Sensor | ||||
| import android.hardware.SensorEvent | ||||
| import android.hardware.SensorEventListener | ||||
| import android.hardware.SensorManager | ||||
| import android.hardware.display.DisplayManager | ||||
| import android.os.Bundle | ||||
| import android.view.Display | ||||
| import android.view.InputDevice | ||||
| import android.view.KeyEvent | ||||
| import android.view.MotionEvent | ||||
| import android.view.Surface | ||||
| import android.view.View | ||||
| import android.view.inputmethod.InputMethodManager | ||||
| import androidx.activity.viewModels | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.content.getSystemService | ||||
| import androidx.core.view.WindowCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.WindowInsetsControllerCompat | ||||
| import androidx.lifecycle.Lifecycle | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.lifecycle.repeatOnLifecycle | ||||
| import androidx.window.layout.WindowInfoTracker | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel | ||||
| import org.yuzu.yuzu_emu.fragments.EmulationFragment | ||||
| import org.yuzu.yuzu_emu.model.Game | ||||
| import org.yuzu.yuzu_emu.utils.ControllerMappingHelper | ||||
| import org.yuzu.yuzu_emu.utils.EmulationMenuSettings | ||||
| import org.yuzu.yuzu_emu.utils.ForegroundService | ||||
| import org.yuzu.yuzu_emu.utils.InputHandler | ||||
| import org.yuzu.yuzu_emu.utils.NfcReader | ||||
| import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable | ||||
| import org.yuzu.yuzu_emu.utils.ThemeHelper | ||||
| import kotlin.math.roundToInt | ||||
|  | ||||
| class EmulationActivity : AppCompatActivity(), SensorEventListener { | ||||
|     private var controllerMappingHelper: ControllerMappingHelper? = null | ||||
|  | ||||
|     var isActivityRecreated = false | ||||
|     private var emulationFragment: EmulationFragment? = null | ||||
|     private lateinit var nfcReader: NfcReader | ||||
|     private lateinit var inputHandler: InputHandler | ||||
|  | ||||
|     private val gyro = FloatArray(3) | ||||
|     private val accel = FloatArray(3) | ||||
|     private var motionTimestamp: Long = 0 | ||||
|     private var flipMotionOrientation: Boolean = false | ||||
|  | ||||
|     private lateinit var game: Game | ||||
|  | ||||
|     private val settingsViewModel: SettingsViewModel by viewModels() | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         stopForegroundService(this) | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         ThemeHelper.setTheme(this) | ||||
|  | ||||
|         settingsViewModel.settings.loadSettings() | ||||
|  | ||||
|         super.onCreate(savedInstanceState) | ||||
|         if (savedInstanceState == null) { | ||||
|             // Get params we were passed | ||||
|             game = intent.parcelable(EXTRA_SELECTED_GAME)!! | ||||
|             isActivityRecreated = false | ||||
|         } else { | ||||
|             isActivityRecreated = true | ||||
|             restoreState(savedInstanceState) | ||||
|         } | ||||
|         controllerMappingHelper = ControllerMappingHelper() | ||||
|  | ||||
|         // Set these options now so that the SurfaceView the game renders into is the right size. | ||||
|         enableFullscreenImmersive() | ||||
|  | ||||
|         setContentView(R.layout.activity_emulation) | ||||
|         window.decorView.setBackgroundColor(getColor(android.R.color.black)) | ||||
|  | ||||
|         // Find or create the EmulationFragment | ||||
|         emulationFragment = | ||||
|             supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment? | ||||
|         if (emulationFragment == null) { | ||||
|             emulationFragment = EmulationFragment.newInstance(game) | ||||
|             supportFragmentManager.beginTransaction() | ||||
|                 .add(R.id.frame_emulation_fragment, emulationFragment!!) | ||||
|                 .commit() | ||||
|         } | ||||
|         title = game.title | ||||
|  | ||||
|         nfcReader = NfcReader(this) | ||||
|         nfcReader.initialize() | ||||
|  | ||||
|         inputHandler = InputHandler() | ||||
|         inputHandler.initialize() | ||||
|  | ||||
|         lifecycleScope.launch(Dispatchers.Main) { | ||||
|             lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { | ||||
|                 WindowInfoTracker.getOrCreate(this@EmulationActivity) | ||||
|                     .windowLayoutInfo(this@EmulationActivity) | ||||
|                     .collect { emulationFragment?.updateCurrentLayout(this@EmulationActivity, it) } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Start a foreground service to prevent the app from getting killed in the background | ||||
|         val startIntent = Intent(this, ForegroundService::class.java) | ||||
|         startForegroundService(startIntent) | ||||
|     } | ||||
|  | ||||
|     override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { | ||||
|         if (event.action == KeyEvent.ACTION_DOWN) { | ||||
|             if (keyCode == KeyEvent.KEYCODE_ENTER) { | ||||
|                 // Special case, we do not support multiline input, dismiss the keyboard. | ||||
|                 val overlayView: View = | ||||
|                     this.findViewById(R.id.surface_input_overlay) | ||||
|                 val im = | ||||
|                     overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager | ||||
|                 im.hideSoftInputFromWindow(overlayView.windowToken, 0) | ||||
|             } else { | ||||
|                 val textChar = event.unicodeChar | ||||
|                 if (textChar == 0) { | ||||
|                     // No text, button input. | ||||
|                     NativeLibrary.submitInlineKeyboardInput(keyCode) | ||||
|                 } else { | ||||
|                     // Text submitted. | ||||
|                     NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString()) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return super.onKeyDown(keyCode, event) | ||||
|     } | ||||
|  | ||||
|     override fun onResume() { | ||||
|         super.onResume() | ||||
|         nfcReader.startScanning() | ||||
|         startMotionSensorListener() | ||||
|  | ||||
|         NativeLibrary.notifyOrientationChange( | ||||
|             EmulationMenuSettings.landscapeScreenLayout, | ||||
|             getAdjustedRotation() | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onPause() { | ||||
|         super.onPause() | ||||
|         nfcReader.stopScanning() | ||||
|         stopMotionSensorListener() | ||||
|     } | ||||
|  | ||||
|     override fun onNewIntent(intent: Intent) { | ||||
|         super.onNewIntent(intent) | ||||
|         setIntent(intent) | ||||
|         nfcReader.onNewIntent(intent) | ||||
|     } | ||||
|  | ||||
|     override fun onSaveInstanceState(outState: Bundle) { | ||||
|         outState.putParcelable(EXTRA_SELECTED_GAME, game) | ||||
|         super.onSaveInstanceState(outState) | ||||
|     } | ||||
|  | ||||
|     override fun dispatchKeyEvent(event: KeyEvent): Boolean { | ||||
|         if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && | ||||
|             event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD | ||||
|         ) { | ||||
|             return super.dispatchKeyEvent(event) | ||||
|         } | ||||
|  | ||||
|         return inputHandler.dispatchKeyEvent(event) | ||||
|     } | ||||
|  | ||||
|     override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { | ||||
|         if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK && | ||||
|             event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD | ||||
|         ) { | ||||
|             return super.dispatchGenericMotionEvent(event) | ||||
|         } | ||||
|  | ||||
|         // Don't attempt to do anything if we are disconnecting a device. | ||||
|         if (event.actionMasked == MotionEvent.ACTION_CANCEL) { | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|         return inputHandler.dispatchGenericMotionEvent(event) | ||||
|     } | ||||
|  | ||||
|     override fun onSensorChanged(event: SensorEvent) { | ||||
|         val rotation = this.display?.rotation | ||||
|         if (rotation == Surface.ROTATION_90) { | ||||
|             flipMotionOrientation = true | ||||
|         } | ||||
|         if (rotation == Surface.ROTATION_270) { | ||||
|             flipMotionOrientation = false | ||||
|         } | ||||
|  | ||||
|         if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) { | ||||
|             if (flipMotionOrientation) { | ||||
|                 accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH | ||||
|                 accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH | ||||
|             } else { | ||||
|                 accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH | ||||
|                 accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH | ||||
|             } | ||||
|             accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH | ||||
|         } | ||||
|         if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { | ||||
|             // Investigate why sensor value is off by 6x | ||||
|             if (flipMotionOrientation) { | ||||
|                 gyro[0] = -event.values[1] / 6.0f | ||||
|                 gyro[1] = event.values[0] / 6.0f | ||||
|             } else { | ||||
|                 gyro[0] = event.values[1] / 6.0f | ||||
|                 gyro[1] = -event.values[0] / 6.0f | ||||
|             } | ||||
|             gyro[2] = event.values[2] / 6.0f | ||||
|         } | ||||
|  | ||||
|         // Only update state on accelerometer data | ||||
|         if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) { | ||||
|             return | ||||
|         } | ||||
|         val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000 | ||||
|         motionTimestamp = event.timestamp | ||||
|         NativeLibrary.onGamePadMotionEvent( | ||||
|             NativeLibrary.Player1Device, | ||||
|             deltaTimestamp, | ||||
|             gyro[0], | ||||
|             gyro[1], | ||||
|             gyro[2], | ||||
|             accel[0], | ||||
|             accel[1], | ||||
|             accel[2] | ||||
|         ) | ||||
|         NativeLibrary.onGamePadMotionEvent( | ||||
|             NativeLibrary.ConsoleDevice, | ||||
|             deltaTimestamp, | ||||
|             gyro[0], | ||||
|             gyro[1], | ||||
|             gyro[2], | ||||
|             accel[0], | ||||
|             accel[1], | ||||
|             accel[2] | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun onAccuracyChanged(sensor: Sensor, i: Int) {} | ||||
|  | ||||
|     private fun getAdjustedRotation():Int { | ||||
|         val rotation = getSystemService<DisplayManager>()!!.getDisplay(Display.DEFAULT_DISPLAY).rotation | ||||
|         val config: Configuration = resources.configuration | ||||
|  | ||||
|         if ((config.screenLayout and Configuration.SCREENLAYOUT_LONG_YES) != 0 || | ||||
|             (config.screenLayout and Configuration.SCREENLAYOUT_LONG_NO) == 0) { | ||||
|             return rotation | ||||
|         } | ||||
|         when (rotation) { | ||||
|             Surface.ROTATION_0 -> return Surface.ROTATION_90 | ||||
|             Surface.ROTATION_90 -> return Surface.ROTATION_0 | ||||
|             Surface.ROTATION_180 -> return Surface.ROTATION_270 | ||||
|             Surface.ROTATION_270 -> return Surface.ROTATION_180 | ||||
|         } | ||||
|         return rotation | ||||
|     } | ||||
|  | ||||
|     private fun restoreState(savedInstanceState: Bundle) { | ||||
|         game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!! | ||||
|     } | ||||
|  | ||||
|     private fun enableFullscreenImmersive() { | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||
|  | ||||
|         WindowInsetsControllerCompat(window, window.decorView).let { controller -> | ||||
|             controller.hide(WindowInsetsCompat.Type.systemBars()) | ||||
|             controller.systemBarsBehavior = | ||||
|                 WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun startMotionSensorListener() { | ||||
|         val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager | ||||
|         val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) | ||||
|         val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) | ||||
|         sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME) | ||||
|         sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME) | ||||
|     } | ||||
|  | ||||
|     private fun stopMotionSensorListener() { | ||||
|         val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager | ||||
|         val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) | ||||
|         val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) | ||||
|  | ||||
|         sensorManager.unregisterListener(this, gyroSensor) | ||||
|         sensorManager.unregisterListener(this, accelSensor) | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val EXTRA_SELECTED_GAME = "SelectedGame" | ||||
|  | ||||
|         fun launch(activity: AppCompatActivity, game: Game) { | ||||
|             val launcher = Intent(activity, EmulationActivity::class.java) | ||||
|             launcher.putExtra(EXTRA_SELECTED_GAME, game) | ||||
|             activity.startActivity(launcher) | ||||
|         } | ||||
|  | ||||
|         fun stopForegroundService(activity: Activity) { | ||||
|             val startIntent = Intent(activity, ForegroundService::class.java) | ||||
|             startIntent.action = ForegroundService.ACTION_STOP | ||||
|             activity.startForegroundService(startIntent) | ||||
|         } | ||||
|  | ||||
|         private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean { | ||||
|             if (view == null) { | ||||
|                 return true | ||||
|             } | ||||
|             val viewBounds = Rect() | ||||
|             view.getGlobalVisibleRect(viewBounds) | ||||
|             return !viewBounds.contains(x.roundToInt(), y.roundToInt()) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,134 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.adapters | ||||
|  | ||||
| import android.graphics.Bitmap | ||||
| import android.graphics.BitmapFactory | ||||
| import android.net.Uri | ||||
| import android.text.TextUtils | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.ImageView | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.documentfile.provider.DocumentFile | ||||
| import androidx.lifecycle.ViewModelProvider | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.preference.PreferenceManager | ||||
| import androidx.recyclerview.widget.AsyncDifferConfig | ||||
| import androidx.recyclerview.widget.DiffUtil | ||||
| import androidx.recyclerview.widget.ListAdapter | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import coil.load | ||||
| import kotlinx.coroutines.launch | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.databinding.CardGameBinding | ||||
| import org.yuzu.yuzu_emu.activities.EmulationActivity | ||||
| import org.yuzu.yuzu_emu.model.Game | ||||
| import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder | ||||
| import org.yuzu.yuzu_emu.model.GamesViewModel | ||||
|  | ||||
| class GameAdapter(private val activity: AppCompatActivity) : | ||||
|     ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()), | ||||
|     View.OnClickListener { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder { | ||||
|         // Create a new view. | ||||
|         val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
|         binding.cardGame.setOnClickListener(this) | ||||
|  | ||||
|         // Use that view to create a ViewHolder. | ||||
|         return GameViewHolder(binding) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: GameViewHolder, position: Int) { | ||||
|         holder.bind(currentList[position]) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int = currentList.size | ||||
|  | ||||
|     /** | ||||
|      * Launches the game that was clicked on. | ||||
|      * | ||||
|      * @param view The card representing the game the user wants to play. | ||||
|      */ | ||||
|     override fun onClick(view: View) { | ||||
|         val holder = view.tag as GameViewHolder | ||||
|  | ||||
|         val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true | ||||
|         if (!gameExists) { | ||||
|             Toast.makeText( | ||||
|                 YuzuApplication.appContext, | ||||
|                 R.string.loader_error_file_not_found, | ||||
|                 Toast.LENGTH_LONG | ||||
|             ).show() | ||||
|  | ||||
|             ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true) | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|         preferences.edit() | ||||
|             .putLong( | ||||
|                 holder.game.keyLastPlayedTime, | ||||
|                 System.currentTimeMillis() | ||||
|             ) | ||||
|             .apply() | ||||
|  | ||||
|         EmulationActivity.launch(activity, holder.game) | ||||
|     } | ||||
|  | ||||
|     inner class GameViewHolder(val binding: CardGameBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|         lateinit var game: Game | ||||
|  | ||||
|         init { | ||||
|             binding.cardGame.tag = this | ||||
|         } | ||||
|  | ||||
|         fun bind(game: Game) { | ||||
|             this.game = game | ||||
|  | ||||
|             binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP | ||||
|             activity.lifecycleScope.launch { | ||||
|                 val bitmap = decodeGameIcon(game.path) | ||||
|                 binding.imageGameScreen.load(bitmap) { | ||||
|                     error(R.drawable.default_icon) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ") | ||||
|  | ||||
|             binding.textGameTitle.postDelayed( | ||||
|                 { | ||||
|                     binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE | ||||
|                     binding.textGameTitle.isSelected = true | ||||
|                 }, | ||||
|                 3000 | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private class DiffCallback : DiffUtil.ItemCallback<Game>() { | ||||
|         override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean { | ||||
|             return oldItem.gameId == newItem.gameId | ||||
|         } | ||||
|  | ||||
|         override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean { | ||||
|             return oldItem == newItem | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun decodeGameIcon(uri: String): Bitmap? { | ||||
|         val data = NativeLibrary.getIcon(uri) | ||||
|         return BitmapFactory.decodeByteArray( | ||||
|             data, | ||||
|             0, | ||||
|             data.size, | ||||
|             BitmapFactory.Options() | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.adapters | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.core.content.res.ResourcesCompat | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding | ||||
| import org.yuzu.yuzu_emu.model.HomeSetting | ||||
|  | ||||
| class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List<HomeSetting>) : | ||||
|     RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(), | ||||
|     View.OnClickListener { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder { | ||||
|         val binding = | ||||
|             CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
|         binding.root.setOnClickListener(this) | ||||
|         return HomeOptionViewHolder(binding) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return options.size | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) { | ||||
|         holder.bind(options[position]) | ||||
|     } | ||||
|  | ||||
|     override fun onClick(view: View) { | ||||
|         val holder = view.tag as HomeOptionViewHolder | ||||
|         holder.option.onClick.invoke() | ||||
|     } | ||||
|  | ||||
|     inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|         lateinit var option: HomeSetting | ||||
|  | ||||
|         init { | ||||
|             itemView.tag = this | ||||
|         } | ||||
|  | ||||
|         fun bind(option: HomeSetting) { | ||||
|             this.option = option | ||||
|             binding.optionTitle.text = activity.resources.getString(option.titleId) | ||||
|             binding.optionDescription.text = activity.resources.getString(option.descriptionId) | ||||
|             binding.optionIcon.setImageDrawable( | ||||
|                 ResourcesCompat.getDrawable( | ||||
|                     activity.resources, | ||||
|                     option.iconId, | ||||
|                     activity.theme | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|             when (option.titleId) { | ||||
|                 R.string.get_early_access -> binding.optionLayout.background = | ||||
|                     ContextCompat.getDrawable( | ||||
|                         binding.optionCard.context, | ||||
|                         R.drawable.premium_background | ||||
|                     ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,54 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.adapters | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import androidx.recyclerview.widget.RecyclerView.ViewHolder | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding | ||||
| import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment | ||||
| import org.yuzu.yuzu_emu.model.License | ||||
|  | ||||
| class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List<License>) : | ||||
|     RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(), | ||||
|     View.OnClickListener { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder { | ||||
|         val binding = | ||||
|             ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
|         binding.root.setOnClickListener(this) | ||||
|         return LicenseViewHolder(binding) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int = licenses.size | ||||
|  | ||||
|     override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) { | ||||
|         holder.bind(licenses[position]) | ||||
|     } | ||||
|  | ||||
|     override fun onClick(view: View) { | ||||
|         val license = (view.tag as LicenseViewHolder).license | ||||
|         LicenseBottomSheetDialogFragment.newInstance(license) | ||||
|             .show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG) | ||||
|     } | ||||
|  | ||||
|     inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) { | ||||
|         lateinit var license: License | ||||
|  | ||||
|         init { | ||||
|             itemView.tag = this | ||||
|         } | ||||
|  | ||||
|         fun bind(license: License) { | ||||
|             this.license = license | ||||
|  | ||||
|             val context = YuzuApplication.appContext | ||||
|             binding.textSettingName.text = context.getString(license.titleId) | ||||
|             binding.textSettingDescription.text = context.getString(license.descriptionId) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.adapters | ||||
|  | ||||
| import android.text.Html | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.content.res.ResourcesCompat | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.google.android.material.button.MaterialButton | ||||
| import org.yuzu.yuzu_emu.databinding.PageSetupBinding | ||||
| import org.yuzu.yuzu_emu.model.SetupPage | ||||
|  | ||||
| class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) : | ||||
|     RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder { | ||||
|         val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false) | ||||
|         return SetupPageViewHolder(binding) | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int = pages.size | ||||
|  | ||||
|     override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) = | ||||
|         holder.bind(pages[position]) | ||||
|  | ||||
|     inner class SetupPageViewHolder(val binding: PageSetupBinding) : | ||||
|         RecyclerView.ViewHolder(binding.root) { | ||||
|         lateinit var page: SetupPage | ||||
|  | ||||
|         init { | ||||
|             itemView.tag = this | ||||
|         } | ||||
|  | ||||
|         fun bind(page: SetupPage) { | ||||
|             this.page = page | ||||
|             binding.icon.setImageDrawable( | ||||
|                 ResourcesCompat.getDrawable( | ||||
|                     activity.resources, | ||||
|                     page.iconId, | ||||
|                     activity.theme | ||||
|                 ) | ||||
|             ) | ||||
|             binding.textTitle.text = activity.resources.getString(page.titleId) | ||||
|             binding.textDescription.text = | ||||
|                 Html.fromHtml(activity.resources.getString(page.descriptionId), 0) | ||||
|  | ||||
|             binding.buttonAction.apply { | ||||
|                 text = activity.resources.getString(page.buttonTextId) | ||||
|                 if (page.buttonIconId != 0) { | ||||
|                     icon = ResourcesCompat.getDrawable( | ||||
|                         activity.resources, | ||||
|                         page.buttonIconId, | ||||
|                         activity.theme | ||||
|                     ) | ||||
|                 } | ||||
|                 iconGravity = | ||||
|                     if (page.leftAlignedIcon) { | ||||
|                         MaterialButton.ICON_GRAVITY_START | ||||
|                     } else { | ||||
|                         MaterialButton.ICON_GRAVITY_END | ||||
|                     } | ||||
|                 setOnClickListener { | ||||
|                     page.buttonAction.invoke() | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,121 @@ | ||||
| // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.applets.keyboard | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.view.KeyEvent | ||||
| import android.view.View | ||||
| import android.view.WindowInsets | ||||
| import android.view.inputmethod.InputMethodManager | ||||
| import androidx.annotation.Keep | ||||
| import androidx.core.view.ViewCompat | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment | ||||
| import java.io.Serializable | ||||
|  | ||||
| @Keep | ||||
| object SoftwareKeyboard { | ||||
|     lateinit var data: KeyboardData | ||||
|     val dataLock = Object() | ||||
|  | ||||
|     private fun executeNormalImpl(config: KeyboardConfig) { | ||||
|         val emulationActivity = NativeLibrary.sEmulationActivity.get() | ||||
|         data = KeyboardData(SwkbdResult.Cancel.ordinal, "") | ||||
|         val fragment = KeyboardDialogFragment.newInstance(config) | ||||
|         fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG) | ||||
|     } | ||||
|  | ||||
|     private fun executeInlineImpl(config: KeyboardConfig) { | ||||
|         val emulationActivity = NativeLibrary.sEmulationActivity.get() | ||||
|  | ||||
|         val overlayView = emulationActivity!!.findViewById<View>(R.id.surface_input_overlay) | ||||
|         val im = | ||||
|             overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager | ||||
|         im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED) | ||||
|  | ||||
|         // There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result. | ||||
|         val handler = Handler(Looper.myLooper()!!) | ||||
|         val delayMs = 500 | ||||
|         handler.postDelayed(object : Runnable { | ||||
|             override fun run() { | ||||
|                 val insets = ViewCompat.getRootWindowInsets(overlayView) | ||||
|                 val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime()) | ||||
|                 if (isKeyboardVisible) { | ||||
|                     handler.postDelayed(this, delayMs.toLong()) | ||||
|                     return | ||||
|                 } | ||||
|  | ||||
|                 // No longer visible, submit the result. | ||||
|                 NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER) | ||||
|             } | ||||
|         }, delayMs.toLong()) | ||||
|     } | ||||
|  | ||||
|     @JvmStatic | ||||
|     fun executeNormal(config: KeyboardConfig): KeyboardData { | ||||
|         NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) } | ||||
|         synchronized(dataLock) { | ||||
|             dataLock.wait() | ||||
|         } | ||||
|         return data | ||||
|     } | ||||
|  | ||||
|     @JvmStatic | ||||
|     fun executeInline(config: KeyboardConfig) { | ||||
|         NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) } | ||||
|     } | ||||
|  | ||||
|     // Corresponds to Service::AM::Applets::SwkbdType | ||||
|     enum class SwkbdType { | ||||
|         Normal, | ||||
|         NumberPad, | ||||
|         Qwerty, | ||||
|         Unknown3, | ||||
|         Latin, | ||||
|         SimplifiedChinese, | ||||
|         TraditionalChinese, | ||||
|         Korean | ||||
|     } | ||||
|  | ||||
|     // Corresponds to Service::AM::Applets::SwkbdPasswordMode | ||||
|     enum class SwkbdPasswordMode { | ||||
|         Disabled, | ||||
|         Enabled | ||||
|     } | ||||
|  | ||||
|     // Corresponds to Service::AM::Applets::SwkbdResult | ||||
|     enum class SwkbdResult { | ||||
|         Ok, | ||||
|         Cancel | ||||
|     } | ||||
|  | ||||
|     @Keep | ||||
|     data class KeyboardConfig( | ||||
|         var ok_text: String? = null, | ||||
|         var header_text: String? = null, | ||||
|         var sub_text: String? = null, | ||||
|         var guide_text: String? = null, | ||||
|         var initial_text: String? = null, | ||||
|         var left_optional_symbol_key: Short = 0, | ||||
|         var right_optional_symbol_key: Short = 0, | ||||
|         var max_text_length: Int = 0, | ||||
|         var min_text_length: Int = 0, | ||||
|         var initial_cursor_position: Int = 0, | ||||
|         var type: Int = 0, | ||||
|         var password_mode: Int = 0, | ||||
|         var text_draw_type: Int = 0, | ||||
|         var key_disable_flags: Int = 0, | ||||
|         var use_blur_background: Boolean = false, | ||||
|         var enable_backspace_button: Boolean = false, | ||||
|         var enable_return_button: Boolean = false, | ||||
|         var disable_cancel_button: Boolean = false | ||||
|     ) : Serializable | ||||
|  | ||||
|     // Corresponds to Frontend::KeyboardData | ||||
|     @Keep | ||||
|     data class KeyboardData(var result: Int, var text: String) | ||||
| } | ||||
| @@ -0,0 +1,100 @@ | ||||
| // SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.applets.keyboard.ui | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.DialogInterface | ||||
| import android.os.Bundle | ||||
| import android.text.InputFilter | ||||
| import android.text.InputType | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard | ||||
| import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig | ||||
| import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding | ||||
| import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable | ||||
|  | ||||
| class KeyboardDialogFragment : DialogFragment() { | ||||
|     private lateinit var binding: DialogEditTextBinding | ||||
|     private lateinit var config: KeyboardConfig | ||||
|  | ||||
|     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||
|         binding = DialogEditTextBinding.inflate(layoutInflater) | ||||
|         config = requireArguments().serializable(CONFIG)!! | ||||
|  | ||||
|         // Set up the input | ||||
|         binding.editText.hint = config.initial_text | ||||
|         binding.editText.isSingleLine = !config.enable_return_button | ||||
|         binding.editText.filters = | ||||
|             arrayOf<InputFilter>(InputFilter.LengthFilter(config.max_text_length)) | ||||
|  | ||||
|         // Handle input type | ||||
|         var inputType: Int | ||||
|         when (config.type) { | ||||
|             SoftwareKeyboard.SwkbdType.Normal.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.Qwerty.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.Unknown3.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.Latin.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal, | ||||
|             SoftwareKeyboard.SwkbdType.Korean.ordinal -> { | ||||
|                 inputType = InputType.TYPE_CLASS_TEXT | ||||
|                 if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { | ||||
|                     inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD | ||||
|                 } | ||||
|             } | ||||
|             SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> { | ||||
|                 inputType = InputType.TYPE_CLASS_NUMBER | ||||
|                 if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { | ||||
|                     inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD | ||||
|                 } | ||||
|             } | ||||
|             else -> { | ||||
|                 inputType = InputType.TYPE_CLASS_TEXT | ||||
|                 if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) { | ||||
|                     inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         binding.editText.inputType = inputType | ||||
|  | ||||
|         val headerText = | ||||
|             config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) } | ||||
|         val okText = | ||||
|             config.ok_text!!.ifEmpty { resources.getString(R.string.submit) } | ||||
|  | ||||
|         return MaterialAlertDialogBuilder(requireContext()) | ||||
|             .setTitle(headerText) | ||||
|             .setView(binding.root) | ||||
|             .setPositiveButton(okText) { _, _ -> | ||||
|                 SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal | ||||
|                 SoftwareKeyboard.data.text = binding.editText.text.toString() | ||||
|             } | ||||
|             .setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ -> | ||||
|                 SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal | ||||
|             } | ||||
|             .create() | ||||
|     } | ||||
|  | ||||
|     override fun onDismiss(dialog: DialogInterface) { | ||||
|         super.onDismiss(dialog) | ||||
|         synchronized(SoftwareKeyboard.dataLock) { | ||||
|             SoftwareKeyboard.dataLock.notifyAll() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "KeyboardDialogFragment" | ||||
|         const val CONFIG = "keyboard_config" | ||||
|  | ||||
|         fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment { | ||||
|             val frag = KeyboardDialogFragment() | ||||
|             val args = Bundle() | ||||
|             args.putSerializable(CONFIG, config) | ||||
|             frag.arguments = args | ||||
|             return frag | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,31 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.disk_shader_cache | ||||
|  | ||||
| import androidx.lifecycle.LiveData | ||||
| import androidx.lifecycle.MutableLiveData | ||||
| import androidx.lifecycle.ViewModel | ||||
|  | ||||
| class ShaderProgressViewModel : ViewModel() { | ||||
|     private val _progress = MutableLiveData(0) | ||||
|     val progress: LiveData<Int> get() = _progress | ||||
|  | ||||
|     private val _max = MutableLiveData(0) | ||||
|     val max: LiveData<Int> get() = _max | ||||
|  | ||||
|     private val _message = MutableLiveData("") | ||||
|     val message: LiveData<String> get() = _message | ||||
|  | ||||
|     fun setProgress(progress: Int) { | ||||
|         _progress.postValue(progress) | ||||
|     } | ||||
|  | ||||
|     fun setMax(max: Int) { | ||||
|         _max.postValue(max) | ||||
|     } | ||||
|  | ||||
|     fun setMessage(msg: String) { | ||||
|         _message.postValue(msg) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,302 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| // SPDX-License-Identifier: MPL-2.0 | ||||
| // Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/) | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features | ||||
|  | ||||
| import android.database.Cursor | ||||
| import android.database.MatrixCursor | ||||
| import android.os.CancellationSignal | ||||
| import android.os.ParcelFileDescriptor | ||||
| import android.provider.DocumentsContract | ||||
| import android.provider.DocumentsProvider | ||||
| import android.webkit.MimeTypeMap | ||||
| import org.yuzu.yuzu_emu.BuildConfig | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.getPublicFilesDir | ||||
| import java.io.* | ||||
|  | ||||
| class DocumentProvider : DocumentsProvider() { | ||||
|     private val baseDirectory: File | ||||
|         get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath) | ||||
|  | ||||
|     companion object { | ||||
|         private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf( | ||||
|             DocumentsContract.Root.COLUMN_ROOT_ID, | ||||
|             DocumentsContract.Root.COLUMN_MIME_TYPES, | ||||
|             DocumentsContract.Root.COLUMN_FLAGS, | ||||
|             DocumentsContract.Root.COLUMN_ICON, | ||||
|             DocumentsContract.Root.COLUMN_TITLE, | ||||
|             DocumentsContract.Root.COLUMN_SUMMARY, | ||||
|             DocumentsContract.Root.COLUMN_DOCUMENT_ID, | ||||
|             DocumentsContract.Root.COLUMN_AVAILABLE_BYTES | ||||
|         ) | ||||
|  | ||||
|         private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf( | ||||
|             DocumentsContract.Document.COLUMN_DOCUMENT_ID, | ||||
|             DocumentsContract.Document.COLUMN_MIME_TYPE, | ||||
|             DocumentsContract.Document.COLUMN_DISPLAY_NAME, | ||||
|             DocumentsContract.Document.COLUMN_LAST_MODIFIED, | ||||
|             DocumentsContract.Document.COLUMN_FLAGS, | ||||
|             DocumentsContract.Document.COLUMN_SIZE | ||||
|         ) | ||||
|  | ||||
|         const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user" | ||||
|         const val ROOT_ID: String = "root" | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(): Boolean { | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return The [File] that corresponds to the document ID supplied by [getDocumentId] | ||||
|      */ | ||||
|     private fun getFile(documentId: String): File { | ||||
|         if (documentId.startsWith(ROOT_ID)) { | ||||
|             val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1)) | ||||
|             if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found") | ||||
|             return file | ||||
|         } else { | ||||
|             throw FileNotFoundException("'$documentId' is not in any known root") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return A unique ID for the provided [File] | ||||
|      */ | ||||
|     private fun getDocumentId(file: File): String { | ||||
|         return "$ROOT_ID/${file.toRelativeString(baseDirectory)}" | ||||
|     } | ||||
|  | ||||
|     override fun queryRoots(projection: Array<out String>?): Cursor { | ||||
|         val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) | ||||
|  | ||||
|         cursor.newRow().apply { | ||||
|             add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) | ||||
|             add(DocumentsContract.Root.COLUMN_SUMMARY, null) | ||||
|             add( | ||||
|                 DocumentsContract.Root.COLUMN_FLAGS, | ||||
|                 DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD | ||||
|             ) | ||||
|             add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) | ||||
|             add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory)) | ||||
|             add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*") | ||||
|             add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace) | ||||
|             add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) | ||||
|         } | ||||
|  | ||||
|         return cursor | ||||
|     } | ||||
|  | ||||
|     override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor { | ||||
|         val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) | ||||
|         return includeFile(cursor, documentId, null) | ||||
|     } | ||||
|  | ||||
|     override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean { | ||||
|         return documentId?.startsWith(parentDocumentId!!) ?: false | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file | ||||
|      */ | ||||
|     private fun File.resolveWithoutConflict(name: String): File { | ||||
|         var file = resolve(name) | ||||
|         if (file.exists()) { | ||||
|             var noConflictId = | ||||
|                 1 // Makes sure two files don't have the same name by adding a number to the end | ||||
|             val extension = name.substringAfterLast('.') | ||||
|             val baseName = name.substringBeforeLast('.') | ||||
|             while (file.exists()) | ||||
|                 file = resolve("$baseName (${noConflictId++}).$extension") | ||||
|         } | ||||
|         return file | ||||
|     } | ||||
|  | ||||
|     override fun createDocument( | ||||
|         parentDocumentId: String?, | ||||
|         mimeType: String?, | ||||
|         displayName: String | ||||
|     ): String { | ||||
|         val parentFile = getFile(parentDocumentId!!) | ||||
|         val newFile = parentFile.resolveWithoutConflict(displayName) | ||||
|  | ||||
|         try { | ||||
|             if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) { | ||||
|                 if (!newFile.mkdir()) | ||||
|                     throw IOException("Failed to create directory") | ||||
|             } else { | ||||
|                 if (!newFile.createNewFile()) | ||||
|                     throw IOException("Failed to create file") | ||||
|             } | ||||
|         } catch (e: IOException) { | ||||
|             throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}") | ||||
|         } | ||||
|  | ||||
|         return getDocumentId(newFile) | ||||
|     } | ||||
|  | ||||
|     override fun deleteDocument(documentId: String?) { | ||||
|         val file = getFile(documentId!!) | ||||
|         if (!file.delete()) | ||||
|             throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||||
|     } | ||||
|  | ||||
|     override fun removeDocument(documentId: String, parentDocumentId: String?) { | ||||
|         val parent = getFile(parentDocumentId!!) | ||||
|         val file = getFile(documentId) | ||||
|  | ||||
|         if (parent == file || file.parentFile == null || file.parentFile!! == parent) { | ||||
|             if (!file.delete()) | ||||
|                 throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||||
|         } else { | ||||
|             throw FileNotFoundException("Couldn't delete document with ID '$documentId'") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun renameDocument(documentId: String?, displayName: String?): String { | ||||
|         if (displayName == null) | ||||
|             throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null") | ||||
|  | ||||
|         val sourceFile = getFile(documentId!!) | ||||
|         val sourceParentFile = sourceFile.parentFile | ||||
|             ?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent") | ||||
|         val destFile = sourceParentFile.resolve(displayName) | ||||
|  | ||||
|         try { | ||||
|             if (!sourceFile.renameTo(destFile)) | ||||
|                 throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'") | ||||
|         } catch (e: Exception) { | ||||
|             throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}") | ||||
|         } | ||||
|  | ||||
|         return getDocumentId(destFile) | ||||
|     } | ||||
|  | ||||
|     private fun copyDocument( | ||||
|         sourceDocumentId: String, sourceParentDocumentId: String, | ||||
|         targetParentDocumentId: String? | ||||
|     ): String { | ||||
|         if (!isChildDocument(sourceParentDocumentId, sourceDocumentId)) | ||||
|             throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'") | ||||
|  | ||||
|         return copyDocument(sourceDocumentId, targetParentDocumentId) | ||||
|     } | ||||
|  | ||||
|     override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String { | ||||
|         val parent = getFile(targetParentDocumentId!!) | ||||
|         val oldFile = getFile(sourceDocumentId) | ||||
|         val newFile = parent.resolveWithoutConflict(oldFile.name) | ||||
|  | ||||
|         try { | ||||
|             if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true))) | ||||
|                 throw IOException("Couldn't create new file") | ||||
|  | ||||
|             FileInputStream(oldFile).use { inStream -> | ||||
|                 FileOutputStream(newFile).use { outStream -> | ||||
|                     inStream.copyTo(outStream) | ||||
|                 } | ||||
|             } | ||||
|         } catch (e: IOException) { | ||||
|             throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}") | ||||
|         } | ||||
|  | ||||
|         return getDocumentId(newFile) | ||||
|     } | ||||
|  | ||||
|     override fun moveDocument( | ||||
|         sourceDocumentId: String, sourceParentDocumentId: String?, | ||||
|         targetParentDocumentId: String? | ||||
|     ): String { | ||||
|         try { | ||||
|             val newDocumentId = copyDocument( | ||||
|                 sourceDocumentId, sourceParentDocumentId!!, | ||||
|                 targetParentDocumentId | ||||
|             ) | ||||
|             removeDocument(sourceDocumentId, sourceParentDocumentId) | ||||
|             return newDocumentId | ||||
|         } catch (e: FileNotFoundException) { | ||||
|             throw FileNotFoundException("Couldn't move document '$sourceDocumentId'") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor { | ||||
|         val localDocumentId = documentId ?: file?.let { getDocumentId(it) } | ||||
|         val localFile = file ?: getFile(documentId!!) | ||||
|  | ||||
|         var flags = 0 | ||||
|         if (localFile.isDirectory && localFile.canWrite()) { | ||||
|             flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE | ||||
|         } else if (localFile.canWrite()) { | ||||
|             flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE | ||||
|             flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE | ||||
|  | ||||
|             flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE | ||||
|             flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE | ||||
|             flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY | ||||
|             flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME | ||||
|         } | ||||
|  | ||||
|         cursor.newRow().apply { | ||||
|             add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId) | ||||
|             add( | ||||
|                 DocumentsContract.Document.COLUMN_DISPLAY_NAME, | ||||
|                 if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name | ||||
|             ) | ||||
|             add(DocumentsContract.Document.COLUMN_SIZE, localFile.length()) | ||||
|             add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile)) | ||||
|             add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified()) | ||||
|             add(DocumentsContract.Document.COLUMN_FLAGS, flags) | ||||
|             if (localFile == baseDirectory) | ||||
|                 add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu) | ||||
|         } | ||||
|  | ||||
|         return cursor | ||||
|     } | ||||
|  | ||||
|     private fun getTypeForFile(file: File): Any { | ||||
|         return if (file.isDirectory) | ||||
|             DocumentsContract.Document.MIME_TYPE_DIR | ||||
|         else | ||||
|             getTypeForName(file.name) | ||||
|     } | ||||
|  | ||||
|     private fun getTypeForName(name: String): Any { | ||||
|         val lastDot = name.lastIndexOf('.') | ||||
|         if (lastDot >= 0) { | ||||
|             val extension = name.substring(lastDot + 1) | ||||
|             val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) | ||||
|             if (mime != null) | ||||
|                 return mime | ||||
|         } | ||||
|         return "application/octect-stream" | ||||
|     } | ||||
|  | ||||
|     override fun queryChildDocuments( | ||||
|         parentDocumentId: String?, | ||||
|         projection: Array<out String>?, | ||||
|         sortOrder: String? | ||||
|     ): Cursor { | ||||
|         var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) | ||||
|  | ||||
|         val parent = getFile(parentDocumentId!!) | ||||
|         for (file in parent.listFiles()!!) | ||||
|             cursor = includeFile(cursor, null, file) | ||||
|  | ||||
|         return cursor | ||||
|     } | ||||
|  | ||||
|     override fun openDocument( | ||||
|         documentId: String?, | ||||
|         mode: String?, | ||||
|         signal: CancellationSignal? | ||||
|     ): ParcelFileDescriptor { | ||||
|         val file = documentId?.let { getFile(it) } | ||||
|         val accessMode = ParcelFileDescriptor.parseMode(mode) | ||||
|         return ParcelFileDescriptor.open(file, accessMode) | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.model | ||||
|  | ||||
| enum class FloatSetting( | ||||
|     override val key: String, | ||||
|     override val section: String, | ||||
|     override val defaultValue: Float | ||||
| ) : AbstractFloatSetting { | ||||
|     // No float settings currently exist | ||||
|     EMPTY_SETTING("", "", 0f); | ||||
|  | ||||
|     override var float: Float = defaultValue | ||||
|  | ||||
|     override val valueAsString: String | ||||
|         get() = float.toString() | ||||
|  | ||||
|     override val isRuntimeEditable: Boolean | ||||
|         get() { | ||||
|             for (setting in NOT_RUNTIME_EDITABLE) { | ||||
|                 if (setting == this) { | ||||
|                     return false | ||||
|                 } | ||||
|             } | ||||
|             return true | ||||
|         } | ||||
|  | ||||
|     companion object { | ||||
|         private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>() | ||||
|  | ||||
|         fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key } | ||||
|  | ||||
|         fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue } | ||||
|     } | ||||
| } | ||||
| @@ -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 } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,37 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.model | ||||
|  | ||||
| /** | ||||
|  * A semantically-related group of Settings objects. These Settings are | ||||
|  * internally stored as a HashMap. | ||||
|  */ | ||||
| class SettingSection(val name: String) { | ||||
|     val settings = HashMap<String, AbstractSetting>() | ||||
|  | ||||
|     /** | ||||
|      * Convenience method; inserts a value directly into the backing HashMap. | ||||
|      * | ||||
|      * @param setting The Setting to be inserted. | ||||
|      */ | ||||
|     fun putSetting(setting: AbstractSetting) { | ||||
|         settings[setting.key!!] = setting | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Convenience method; gets a value directly from the backing HashMap. | ||||
|      * | ||||
|      * @param key Used to retrieve the Setting. | ||||
|      * @return A Setting object (you should probably cast this before using) | ||||
|      */ | ||||
|     fun getSetting(key: String): AbstractSetting? { | ||||
|         return settings[key] | ||||
|     } | ||||
|  | ||||
|     fun mergeSection(settingSection: SettingSection) { | ||||
|         for (setting in settingSection.settings.values) { | ||||
|             putSetting(setting) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,158 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.model | ||||
|  | ||||
| import android.text.TextUtils | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView | ||||
| import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile | ||||
| import java.util.* | ||||
|  | ||||
| class Settings { | ||||
|     private var gameId: String? = null | ||||
|  | ||||
|     var isLoaded = false | ||||
|  | ||||
|     /** | ||||
|      * A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null | ||||
|      * when getting a key not already in the map | ||||
|      */ | ||||
|     class SettingsSectionMap : HashMap<String, SettingSection?>() { | ||||
|         override operator fun get(key: String): SettingSection? { | ||||
|             if (!super.containsKey(key)) { | ||||
|                 val section = SettingSection(key) | ||||
|                 super.put(key, section) | ||||
|                 return section | ||||
|             } | ||||
|             return super.get(key) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     var sections: HashMap<String, SettingSection?> = SettingsSectionMap() | ||||
|  | ||||
|     fun getSection(sectionName: String): SettingSection? { | ||||
|         return sections[sectionName] | ||||
|     } | ||||
|  | ||||
|     val isEmpty: Boolean | ||||
|         get() = sections.isEmpty() | ||||
|  | ||||
|     fun loadSettings(view: SettingsActivityView? = null) { | ||||
|         sections = SettingsSectionMap() | ||||
|         loadYuzuSettings(view) | ||||
|         if (!TextUtils.isEmpty(gameId)) { | ||||
|             loadCustomGameSettings(gameId!!, view) | ||||
|         } | ||||
|         isLoaded = true | ||||
|     } | ||||
|  | ||||
|     private fun loadYuzuSettings(view: SettingsActivityView?) { | ||||
|         for ((fileName) in configFileSectionsMap) { | ||||
|             sections.putAll(SettingsFile.readFile(fileName, view)) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView?) { | ||||
|         // Custom game settings | ||||
|         mergeSections(SettingsFile.readCustomGameSettings(gameId, view)) | ||||
|     } | ||||
|  | ||||
|     private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) { | ||||
|         for ((key, updatedSection) in updatedSections) { | ||||
|             if (sections.containsKey(key)) { | ||||
|                 val originalSection = sections[key] | ||||
|                 originalSection!!.mergeSection(updatedSection!!) | ||||
|             } else { | ||||
|                 sections[key] = updatedSection | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun loadSettings(gameId: String, view: SettingsActivityView) { | ||||
|         this.gameId = gameId | ||||
|         loadSettings(view) | ||||
|     } | ||||
|  | ||||
|     fun saveSettings(view: SettingsActivityView) { | ||||
|         if (TextUtils.isEmpty(gameId)) { | ||||
|             view.showToastMessage( | ||||
|                 YuzuApplication.appContext.getString(R.string.ini_saved), | ||||
|                 false | ||||
|             ) | ||||
|  | ||||
|             for ((fileName, sectionNames) in configFileSectionsMap) { | ||||
|                 val iniSections = TreeMap<String, SettingSection>() | ||||
|                 for (section in sectionNames) { | ||||
|                     iniSections[section] = sections[section]!! | ||||
|                 } | ||||
|  | ||||
|                 SettingsFile.saveFile(fileName, iniSections, view) | ||||
|             } | ||||
|         } else { | ||||
|             // Custom game settings | ||||
|             view.showToastMessage( | ||||
|                 YuzuApplication.appContext.getString(R.string.gameid_saved, gameId), | ||||
|                 false | ||||
|             ) | ||||
|  | ||||
|             SettingsFile.saveCustomGameSettings(gameId, sections) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val SECTION_GENERAL = "General" | ||||
|         const val SECTION_SYSTEM = "System" | ||||
|         const val SECTION_RENDERER = "Renderer" | ||||
|         const val SECTION_AUDIO = "Audio" | ||||
|         const val SECTION_CPU = "Cpu" | ||||
|         const val SECTION_THEME = "Theme" | ||||
|         const val SECTION_DEBUG = "Debug" | ||||
|  | ||||
|         const val PREF_OVERLAY_INIT = "OverlayInit" | ||||
|         const val PREF_CONTROL_SCALE = "controlScale" | ||||
|         const val PREF_CONTROL_OPACITY = "controlOpacity" | ||||
|         const val PREF_TOUCH_ENABLED = "isTouchEnabled" | ||||
|         const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0" | ||||
|         const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1" | ||||
|         const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2" | ||||
|         const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3" | ||||
|         const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4" | ||||
|         const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5" | ||||
|         const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6" | ||||
|         const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7" | ||||
|         const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8" | ||||
|         const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9" | ||||
|         const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10" | ||||
|         const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11" | ||||
|         const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12" | ||||
|         const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13" | ||||
|         const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14" | ||||
|  | ||||
|         const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter" | ||||
|         const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable" | ||||
|         const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics" | ||||
|         const val PREF_MENU_SETTINGS_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout" | ||||
|         const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps" | ||||
|         const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay" | ||||
|  | ||||
|         const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch" | ||||
|         const val PREF_THEME = "Theme" | ||||
|         const val PREF_THEME_MODE = "ThemeMode" | ||||
|         const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds" | ||||
|  | ||||
|         private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap() | ||||
|  | ||||
|         init { | ||||
|             configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] = | ||||
|                 listOf( | ||||
|                     SECTION_GENERAL, | ||||
|                     SECTION_SYSTEM, | ||||
|                     SECTION_RENDERER, | ||||
|                     SECTION_AUDIO, | ||||
|                     SECTION_CPU | ||||
|                 ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
| } | ||||
| @@ -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 } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.model.view | ||||
|  | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.StringSetting | ||||
|  | ||||
| class StringSingleChoiceSetting( | ||||
|     val key: String? = null, | ||||
|     setting: AbstractSetting?, | ||||
|     titleId: Int, | ||||
|     descriptionId: Int, | ||||
|     val choicesId: Array<String>, | ||||
|     private val valuesId: Array<String>?, | ||||
|     private val defaultValue: String? = null | ||||
| ) : SettingsItem(setting, titleId, descriptionId) { | ||||
|     override val type = TYPE_STRING_SINGLE_CHOICE | ||||
|  | ||||
|     fun getValueAt(index: Int): String? { | ||||
|         if (valuesId == null) return null | ||||
|         return if (index >= 0 && index < valuesId.size) { | ||||
|             valuesId[index] | ||||
|         } else "" | ||||
|     } | ||||
|  | ||||
|     val selectedValue: String | ||||
|         get() = if (setting != null) { | ||||
|             val setting = setting as AbstractStringSetting | ||||
|             setting.string | ||||
|         } else { | ||||
|             defaultValue!! | ||||
|         } | ||||
|     val selectValueIndex: Int | ||||
|         get() { | ||||
|             val selectedValue = selectedValue | ||||
|             for (i in valuesId!!.indices) { | ||||
|                 if (valuesId[i] == selectedValue) { | ||||
|                     return i | ||||
|                 } | ||||
|             } | ||||
|             return -1 | ||||
|         } | ||||
|  | ||||
|     /** | ||||
|      * Write a value to the backing int. If that int was previously null, | ||||
|      * initializes a new one and returns it, so it can be added to the Hashmap. | ||||
|      * | ||||
|      * @param selection New value of the int. | ||||
|      * @return the existing setting with the new value applied. | ||||
|      */ | ||||
|     fun setSelectedValue(selection: String): AbstractStringSetting { | ||||
|         val stringSetting = setting as AbstractStringSetting | ||||
|         stringSetting.string = selection | ||||
|         return stringSetting | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -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() | ||||
| } | ||||
| @@ -0,0 +1,340 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.ui | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.DialogInterface | ||||
| import android.icu.util.Calendar | ||||
| import android.icu.util.TimeZone | ||||
| import android.text.format.DateFormat | ||||
| import android.view.LayoutInflater | ||||
| import android.view.ViewGroup | ||||
| import android.widget.TextView | ||||
| import androidx.appcompat.app.AlertDialog | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.fragment.app.setFragmentResultListener | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.google.android.material.datepicker.MaterialDatePicker | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import com.google.android.material.slider.Slider | ||||
| import com.google.android.material.timepicker.MaterialTimePicker | ||||
| import com.google.android.material.timepicker.TimeFormat | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.databinding.DialogSliderBinding | ||||
| import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding | ||||
| import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding | ||||
| import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.FloatSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.view.* | ||||
| import org.yuzu.yuzu_emu.features.settings.ui.viewholder.* | ||||
|  | ||||
| class SettingsAdapter( | ||||
|     private val fragmentView: SettingsFragmentView, | ||||
|     private val context: Context | ||||
| ) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener { | ||||
|     private var settings: ArrayList<SettingsItem>? = null | ||||
|     private var clickedItem: SettingsItem? = null | ||||
|     private var clickedPosition: Int | ||||
|     private var dialog: AlertDialog? = null | ||||
|     private var sliderProgress = 0 | ||||
|     private var textSliderValue: TextView? = null | ||||
|  | ||||
|     private var defaultCancelListener = | ||||
|         DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() } | ||||
|  | ||||
|     init { | ||||
|         clickedPosition = -1 | ||||
|     } | ||||
|  | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder { | ||||
|         val inflater = LayoutInflater.from(parent.context) | ||||
|         return when (viewType) { | ||||
|             SettingsItem.TYPE_HEADER -> { | ||||
|                 HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_SWITCH -> { | ||||
|                 SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> { | ||||
|                 SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_SLIDER -> { | ||||
|                 SliderViewHolder(ListItemSettingBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_SUBMENU -> { | ||||
|                 SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_DATETIME_SETTING -> { | ||||
|                 DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             SettingsItem.TYPE_RUNNABLE -> { | ||||
|                 RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this) | ||||
|             } | ||||
|  | ||||
|             else -> { | ||||
|                 // TODO: Create an error view since we can't return null now | ||||
|                 HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(holder: SettingViewHolder, position: Int) { | ||||
|         holder.bind(getItem(position)) | ||||
|     } | ||||
|  | ||||
|     private fun getItem(position: Int): SettingsItem { | ||||
|         return settings!![position] | ||||
|     } | ||||
|  | ||||
|     override fun getItemCount(): Int { | ||||
|         return if (settings != null) { | ||||
|             settings!!.size | ||||
|         } else { | ||||
|             0 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun getItemViewType(position: Int): Int { | ||||
|         return getItem(position).type | ||||
|     } | ||||
|  | ||||
|     fun setSettingsList(settings: ArrayList<SettingsItem>?) { | ||||
|         this.settings = settings | ||||
|         notifyDataSetChanged() | ||||
|     } | ||||
|  | ||||
|     fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) { | ||||
|         val setting = item.setChecked(checked) | ||||
|         fragmentView.putSetting(setting) | ||||
|         fragmentView.onSettingChanged() | ||||
|     } | ||||
|  | ||||
|     private fun onSingleChoiceClick(item: SingleChoiceSetting) { | ||||
|         clickedItem = item | ||||
|         val value = getSelectionForSingleChoiceValue(item) | ||||
|         dialog = MaterialAlertDialogBuilder(context) | ||||
|             .setTitle(item.nameId) | ||||
|             .setSingleChoiceItems(item.choicesId, value, this) | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) { | ||||
|         clickedPosition = position | ||||
|         onSingleChoiceClick(item) | ||||
|     } | ||||
|  | ||||
|     private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) { | ||||
|         clickedItem = item | ||||
|         dialog = MaterialAlertDialogBuilder(context) | ||||
|             .setTitle(item.nameId) | ||||
|             .setSingleChoiceItems(item.choicesId, item.selectValueIndex, this) | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) { | ||||
|         clickedPosition = position | ||||
|         onStringSingleChoiceClick(item) | ||||
|     } | ||||
|  | ||||
|     fun onDateTimeClick(item: DateTimeSetting, position: Int) { | ||||
|         clickedItem = item | ||||
|         clickedPosition = position | ||||
|         val storedTime = java.lang.Long.decode(item.value) * 1000 | ||||
|  | ||||
|         // Helper to extract hour and minute from epoch time | ||||
|         val calendar: Calendar = Calendar.getInstance() | ||||
|         calendar.timeInMillis = storedTime | ||||
|         calendar.timeZone = TimeZone.getTimeZone("UTC") | ||||
|  | ||||
|         var timeFormat: Int = TimeFormat.CLOCK_12H | ||||
|         if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) { | ||||
|             timeFormat = TimeFormat.CLOCK_24H | ||||
|         } | ||||
|  | ||||
|         val datePicker: MaterialDatePicker<Long> = MaterialDatePicker.Builder.datePicker() | ||||
|             .setSelection(storedTime) | ||||
|             .setTitleText(R.string.select_rtc_date) | ||||
|             .build() | ||||
|         val timePicker: MaterialTimePicker = MaterialTimePicker.Builder() | ||||
|             .setTimeFormat(timeFormat) | ||||
|             .setHour(calendar.get(Calendar.HOUR_OF_DAY)) | ||||
|             .setMinute(calendar.get(Calendar.MINUTE)) | ||||
|             .setTitleText(R.string.select_rtc_time) | ||||
|             .build() | ||||
|  | ||||
|         datePicker.addOnPositiveButtonClickListener { | ||||
|             timePicker.show( | ||||
|                 (fragmentView.activityView as AppCompatActivity).supportFragmentManager, | ||||
|                 "TimePicker" | ||||
|             ) | ||||
|         } | ||||
|         timePicker.addOnPositiveButtonClickListener { | ||||
|             var epochTime: Long = datePicker.selection!! / 1000 | ||||
|             epochTime += timePicker.hour.toLong() * 60 * 60 | ||||
|             epochTime += timePicker.minute.toLong() * 60 | ||||
|             val rtcString = epochTime.toString() | ||||
|             if (item.value != rtcString) { | ||||
|                 fragmentView.onSettingChanged() | ||||
|             } | ||||
|             notifyItemChanged(clickedPosition) | ||||
|             val setting = item.setSelectedValue(rtcString) | ||||
|             fragmentView.putSetting(setting) | ||||
|             clickedItem = null | ||||
|         } | ||||
|         datePicker.show( | ||||
|             (fragmentView.activityView as AppCompatActivity).supportFragmentManager, | ||||
|             "DatePicker" | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     fun onSliderClick(item: SliderSetting, position: Int) { | ||||
|         clickedItem = item | ||||
|         clickedPosition = position | ||||
|         sliderProgress = item.selectedValue | ||||
|  | ||||
|         val inflater = LayoutInflater.from(context) | ||||
|         val sliderBinding = DialogSliderBinding.inflate(inflater) | ||||
|  | ||||
|         textSliderValue = sliderBinding.textValue | ||||
|         textSliderValue!!.text = sliderProgress.toString() | ||||
|         sliderBinding.textUnits.text = item.units | ||||
|  | ||||
|         sliderBinding.slider.apply { | ||||
|             valueFrom = item.min.toFloat() | ||||
|             valueTo = item.max.toFloat() | ||||
|             value = sliderProgress.toFloat() | ||||
|             addOnChangeListener { _: Slider, value: Float, _: Boolean -> | ||||
|                 sliderProgress = value.toInt() | ||||
|                 textSliderValue!!.text = sliderProgress.toString() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         dialog = MaterialAlertDialogBuilder(context) | ||||
|             .setTitle(item.nameId) | ||||
|             .setView(sliderBinding.root) | ||||
|             .setPositiveButton(android.R.string.ok, this) | ||||
|             .setNegativeButton(android.R.string.cancel, defaultCancelListener) | ||||
|             .setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int -> | ||||
|                 sliderBinding.slider.value = item.defaultValue!!.toFloat() | ||||
|                 onClick(dialog, which) | ||||
|             } | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     fun onSubmenuClick(item: SubmenuSetting) { | ||||
|         fragmentView.loadSubMenu(item.menuKey) | ||||
|     } | ||||
|  | ||||
|     override fun onClick(dialog: DialogInterface, which: Int) { | ||||
|         when (clickedItem) { | ||||
|             is SingleChoiceSetting -> { | ||||
|                 val scSetting = clickedItem as SingleChoiceSetting | ||||
|                 val value = getValueForSingleChoiceSelection(scSetting, which) | ||||
|                 if (scSetting.selectedValue != value) { | ||||
|                     fragmentView.onSettingChanged() | ||||
|                 } | ||||
|  | ||||
|                 // Get the backing Setting, which may be null (if for example it was missing from the file) | ||||
|                 val setting = scSetting.setSelectedValue(value) | ||||
|                 fragmentView.putSetting(setting) | ||||
|                 closeDialog() | ||||
|             } | ||||
|  | ||||
|             is StringSingleChoiceSetting -> { | ||||
|                 val scSetting = clickedItem as StringSingleChoiceSetting | ||||
|                 val value = scSetting.getValueAt(which) | ||||
|                 if (scSetting.selectedValue != value) fragmentView.onSettingChanged() | ||||
|                 val setting = scSetting.setSelectedValue(value!!) | ||||
|                 fragmentView.putSetting(setting) | ||||
|                 closeDialog() | ||||
|             } | ||||
|  | ||||
|             is SliderSetting -> { | ||||
|                 val sliderSetting = clickedItem as SliderSetting | ||||
|                 if (sliderSetting.selectedValue != sliderProgress) { | ||||
|                     fragmentView.onSettingChanged() | ||||
|                 } | ||||
|                 if (sliderSetting.setting is FloatSetting) { | ||||
|                     val value = sliderProgress.toFloat() | ||||
|                     val setting = sliderSetting.setSelectedValue(value) | ||||
|                     fragmentView.putSetting(setting) | ||||
|                 } else { | ||||
|                     val setting = sliderSetting.setSelectedValue(sliderProgress) | ||||
|                     fragmentView.putSetting(setting) | ||||
|                 } | ||||
|                 closeDialog() | ||||
|             } | ||||
|         } | ||||
|         clickedItem = null | ||||
|         sliderProgress = -1 | ||||
|     } | ||||
|  | ||||
|     fun onLongClick(setting: AbstractSetting, position: Int): Boolean { | ||||
|         MaterialAlertDialogBuilder(context) | ||||
|             .setMessage(R.string.reset_setting_confirmation) | ||||
|             .setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int -> | ||||
|                 when (setting) { | ||||
|                     is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean | ||||
|                     is AbstractFloatSetting -> setting.float = setting.defaultValue as Float | ||||
|                     is AbstractIntSetting -> setting.int = setting.defaultValue as Int | ||||
|                     is AbstractStringSetting -> setting.string = setting.defaultValue as String | ||||
|                 } | ||||
|                 notifyItemChanged(position) | ||||
|                 fragmentView.onSettingChanged() | ||||
|             } | ||||
|             .setNegativeButton(android.R.string.cancel, null) | ||||
|             .show() | ||||
|  | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     fun closeDialog() { | ||||
|         if (dialog != null) { | ||||
|             if (clickedPosition != -1) { | ||||
|                 notifyItemChanged(clickedPosition) | ||||
|                 clickedPosition = -1 | ||||
|             } | ||||
|             dialog!!.dismiss() | ||||
|             dialog = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int { | ||||
|         val valuesId = item.valuesId | ||||
|         return if (valuesId > 0) { | ||||
|             val valuesArray = context.resources.getIntArray(valuesId) | ||||
|             valuesArray[which] | ||||
|         } else { | ||||
|             which | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int { | ||||
|         val value = item.selectedValue | ||||
|         val valuesId = item.valuesId | ||||
|         if (valuesId > 0) { | ||||
|             val valuesArray = context.resources.getIntArray(valuesId) | ||||
|             for (index in valuesArray.indices) { | ||||
|                 val current = valuesArray[index] | ||||
|                 if (current == value) { | ||||
|                     return index | ||||
|                 } | ||||
|             } | ||||
|         } else { | ||||
|             return value | ||||
|         } | ||||
|         return -1 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,122 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.ui | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.updatePadding | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import com.google.android.material.divider.MaterialDividerItemDecoration | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem | ||||
|  | ||||
| class SettingsFragment : Fragment(), SettingsFragmentView { | ||||
|     override var activityView: SettingsActivityView? = null | ||||
|  | ||||
|     private val fragmentPresenter = SettingsFragmentPresenter(this) | ||||
|     private var settingsAdapter: SettingsAdapter? = null | ||||
|  | ||||
|     private var _binding: FragmentSettingsBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     override fun onAttach(context: Context) { | ||||
|         super.onAttach(context) | ||||
|         activityView = requireActivity() as SettingsActivityView | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG) | ||||
|         val gameId = requireArguments().getString(ARGUMENT_GAME_ID) | ||||
|         fragmentPresenter.onCreate(menuTag!!, gameId!!) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentSettingsBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         settingsAdapter = SettingsAdapter(this, requireActivity()) | ||||
|         val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) | ||||
|         dividerDecoration.isLastItemDecorated = false | ||||
|         binding.listSettings.apply { | ||||
|             adapter = settingsAdapter | ||||
|             layoutManager = LinearLayoutManager(activity) | ||||
|             addItemDecoration(dividerDecoration) | ||||
|         } | ||||
|         fragmentPresenter.onViewCreated() | ||||
|  | ||||
|         setInsets() | ||||
|     } | ||||
|  | ||||
|     override fun onDetach() { | ||||
|         super.onDetach() | ||||
|         activityView = null | ||||
|         if (settingsAdapter != null) { | ||||
|             settingsAdapter!!.closeDialog() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun showSettingsList(settingsList: ArrayList<SettingsItem>) { | ||||
|         settingsAdapter!!.setSettingsList(settingsList) | ||||
|     } | ||||
|  | ||||
|     override fun loadSettingsList() { | ||||
|         fragmentPresenter.loadSettingsList() | ||||
|     } | ||||
|  | ||||
|     override fun loadSubMenu(menuKey: String) { | ||||
|         activityView!!.showSettingsFragment( | ||||
|             menuKey, | ||||
|             true, | ||||
|             requireArguments().getString(ARGUMENT_GAME_ID)!! | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     override fun showToastMessage(message: String?, is_long: Boolean) { | ||||
|         activityView!!.showToastMessage(message!!, is_long) | ||||
|     } | ||||
|  | ||||
|     override fun putSetting(setting: AbstractSetting) { | ||||
|         fragmentPresenter.putSetting(setting) | ||||
|     } | ||||
|  | ||||
|     override fun onSettingChanged() { | ||||
|         activityView!!.onSettingChanged() | ||||
|     } | ||||
|  | ||||
|     private fun setInsets() { | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat -> | ||||
|             val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) | ||||
|             view.updatePadding(bottom = insets.bottom) | ||||
|             windowInsets | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private const val ARGUMENT_MENU_TAG = "menu_tag" | ||||
|         private const val ARGUMENT_GAME_ID = "game_id" | ||||
|  | ||||
|         fun newInstance(menuTag: String?, gameId: String?): Fragment { | ||||
|             val fragment = SettingsFragment() | ||||
|             val arguments = Bundle() | ||||
|             arguments.putString(ARGUMENT_MENU_TAG, menuTag) | ||||
|             arguments.putString(ARGUMENT_GAME_ID, gameId) | ||||
|             fragment.arguments = arguments | ||||
|             return fragment | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,465 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.ui | ||||
|  | ||||
| import android.content.SharedPreferences | ||||
| import android.os.Build | ||||
| import android.text.TextUtils | ||||
| import androidx.preference.PreferenceManager | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.IntSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.Settings | ||||
| import org.yuzu.yuzu_emu.features.settings.model.StringSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.view.* | ||||
| import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile | ||||
| import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment | ||||
| import org.yuzu.yuzu_emu.utils.ThemeHelper | ||||
|  | ||||
| class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) { | ||||
|     private var menuTag: String? = null | ||||
|     private lateinit var gameId: String | ||||
|     private var settingsList: ArrayList<SettingsItem>? = null | ||||
|  | ||||
|     private val settingsActivity get() = fragmentView.activityView as SettingsActivity | ||||
|     private val settings get() = fragmentView.activityView!!.settings | ||||
|  | ||||
|     private lateinit var preferences: SharedPreferences | ||||
|  | ||||
|     fun onCreate(menuTag: String, gameId: String) { | ||||
|         this.gameId = gameId | ||||
|         this.menuTag = menuTag | ||||
|     } | ||||
|  | ||||
|     fun onViewCreated() { | ||||
|         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|         loadSettingsList() | ||||
|     } | ||||
|  | ||||
|     fun putSetting(setting: AbstractSetting) { | ||||
|         if (setting.section == null) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val section = settings.getSection(setting.section!!)!! | ||||
|         if (section.getSetting(setting.key!!) == null) { | ||||
|             section.putSetting(setting) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun loadSettingsList() { | ||||
|         if (!TextUtils.isEmpty(gameId)) { | ||||
|             settingsActivity.setToolbarTitle("Game Settings: $gameId") | ||||
|         } | ||||
|         val sl = ArrayList<SettingsItem>() | ||||
|         if (menuTag == null) { | ||||
|             return | ||||
|         } | ||||
|         when (menuTag) { | ||||
|             SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl) | ||||
|             Settings.SECTION_GENERAL -> addGeneralSettings(sl) | ||||
|             Settings.SECTION_SYSTEM -> addSystemSettings(sl) | ||||
|             Settings.SECTION_RENDERER -> addGraphicsSettings(sl) | ||||
|             Settings.SECTION_AUDIO -> addAudioSettings(sl) | ||||
|             Settings.SECTION_THEME -> addThemeSettings(sl) | ||||
|             Settings.SECTION_DEBUG -> addDebugSettings(sl) | ||||
|             else -> { | ||||
|                 fragmentView.showToastMessage("Unimplemented menu", false) | ||||
|                 return | ||||
|             } | ||||
|         } | ||||
|         settingsList = sl | ||||
|         fragmentView.showSettingsList(settingsList!!) | ||||
|     } | ||||
|  | ||||
|     private fun addConfigSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.advanced_settings)) | ||||
|         sl.apply { | ||||
|             add( | ||||
|                 SubmenuSetting( | ||||
|                     R.string.preferences_general, | ||||
|                     0, | ||||
|                     Settings.SECTION_GENERAL | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SubmenuSetting( | ||||
|                     R.string.preferences_system, | ||||
|                     0, | ||||
|                     Settings.SECTION_SYSTEM | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SubmenuSetting( | ||||
|                     R.string.preferences_graphics, | ||||
|                     0, | ||||
|                     Settings.SECTION_RENDERER | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SubmenuSetting( | ||||
|                     R.string.preferences_audio, | ||||
|                     0, | ||||
|                     Settings.SECTION_AUDIO | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SubmenuSetting( | ||||
|                     R.string.preferences_debug, | ||||
|                     0, | ||||
|                     Settings.SECTION_DEBUG | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 RunnableSetting( | ||||
|                     R.string.reset_to_default, | ||||
|                     0, | ||||
|                     false | ||||
|                 ) { | ||||
|                     ResetSettingsDialogFragment().show( | ||||
|                         settingsActivity.supportFragmentManager, | ||||
|                         ResetSettingsDialogFragment.TAG | ||||
|                     ) | ||||
|                 } | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun addGeneralSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general)) | ||||
|         sl.apply { | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.RENDERER_USE_SPEED_LIMIT, | ||||
|                     R.string.frame_limit_enable, | ||||
|                     R.string.frame_limit_enable_description, | ||||
|                     IntSetting.RENDERER_USE_SPEED_LIMIT.key, | ||||
|                     IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SliderSetting( | ||||
|                     IntSetting.RENDERER_SPEED_LIMIT, | ||||
|                     R.string.frame_limit_slider, | ||||
|                     R.string.frame_limit_slider_description, | ||||
|                     1, | ||||
|                     200, | ||||
|                     "%", | ||||
|                     IntSetting.RENDERER_SPEED_LIMIT.key, | ||||
|                     IntSetting.RENDERER_SPEED_LIMIT.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.CPU_ACCURACY, | ||||
|                     R.string.cpu_accuracy, | ||||
|                     0, | ||||
|                     R.array.cpuAccuracyNames, | ||||
|                     R.array.cpuAccuracyValues, | ||||
|                     IntSetting.CPU_ACCURACY.key, | ||||
|                     IntSetting.CPU_ACCURACY.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun addSystemSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system)) | ||||
|         sl.apply { | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.USE_DOCKED_MODE, | ||||
|                     R.string.use_docked_mode, | ||||
|                     R.string.use_docked_mode_description, | ||||
|                     IntSetting.USE_DOCKED_MODE.key, | ||||
|                     IntSetting.USE_DOCKED_MODE.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.REGION_INDEX, | ||||
|                     R.string.emulated_region, | ||||
|                     0, | ||||
|                     R.array.regionNames, | ||||
|                     R.array.regionValues, | ||||
|                     IntSetting.REGION_INDEX.key, | ||||
|                     IntSetting.REGION_INDEX.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.LANGUAGE_INDEX, | ||||
|                     R.string.emulated_language, | ||||
|                     0, | ||||
|                     R.array.languageNames, | ||||
|                     R.array.languageValues, | ||||
|                     IntSetting.LANGUAGE_INDEX.key, | ||||
|                     IntSetting.LANGUAGE_INDEX.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     BooleanSetting.USE_CUSTOM_RTC, | ||||
|                     R.string.use_custom_rtc, | ||||
|                     R.string.use_custom_rtc_description, | ||||
|                     BooleanSetting.USE_CUSTOM_RTC.key, | ||||
|                     BooleanSetting.USE_CUSTOM_RTC.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 DateTimeSetting( | ||||
|                     StringSetting.CUSTOM_RTC, | ||||
|                     R.string.set_custom_rtc, | ||||
|                     0, | ||||
|                     StringSetting.CUSTOM_RTC.key, | ||||
|                     StringSetting.CUSTOM_RTC.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics)) | ||||
|         sl.apply { | ||||
|  | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_ACCURACY, | ||||
|                     R.string.renderer_accuracy, | ||||
|                     0, | ||||
|                     R.array.rendererAccuracyNames, | ||||
|                     R.array.rendererAccuracyValues, | ||||
|                     IntSetting.RENDERER_ACCURACY.key, | ||||
|                     IntSetting.RENDERER_ACCURACY.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_RESOLUTION, | ||||
|                     R.string.renderer_resolution, | ||||
|                     0, | ||||
|                     R.array.rendererResolutionNames, | ||||
|                     R.array.rendererResolutionValues, | ||||
|                     IntSetting.RENDERER_RESOLUTION.key, | ||||
|                     IntSetting.RENDERER_RESOLUTION.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_VSYNC, | ||||
|                     R.string.renderer_vsync, | ||||
|                     0, | ||||
|                     R.array.rendererVSyncNames, | ||||
|                     R.array.rendererVSyncValues, | ||||
|                     IntSetting.RENDERER_VSYNC.key, | ||||
|                     IntSetting.RENDERER_VSYNC.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_SCALING_FILTER, | ||||
|                     R.string.renderer_scaling_filter, | ||||
|                     0, | ||||
|                     R.array.rendererScalingFilterNames, | ||||
|                     R.array.rendererScalingFilterValues, | ||||
|                     IntSetting.RENDERER_SCALING_FILTER.key, | ||||
|                     IntSetting.RENDERER_SCALING_FILTER.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_ANTI_ALIASING, | ||||
|                     R.string.renderer_anti_aliasing, | ||||
|                     0, | ||||
|                     R.array.rendererAntiAliasingNames, | ||||
|                     R.array.rendererAntiAliasingValues, | ||||
|                     IntSetting.RENDERER_ANTI_ALIASING.key, | ||||
|                     IntSetting.RENDERER_ANTI_ALIASING.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_ASPECT_RATIO, | ||||
|                     R.string.renderer_aspect_ratio, | ||||
|                     0, | ||||
|                     R.array.rendererAspectRatioNames, | ||||
|                     R.array.rendererAspectRatioValues, | ||||
|                     IntSetting.RENDERER_ASPECT_RATIO.key, | ||||
|                     IntSetting.RENDERER_ASPECT_RATIO.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.RENDERER_USE_DISK_SHADER_CACHE, | ||||
|                     R.string.use_disk_shader_cache, | ||||
|                     R.string.use_disk_shader_cache_description, | ||||
|                     IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key, | ||||
|                     IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.RENDERER_FORCE_MAX_CLOCK, | ||||
|                     R.string.renderer_force_max_clock, | ||||
|                     R.string.renderer_force_max_clock_description, | ||||
|                     IntSetting.RENDERER_FORCE_MAX_CLOCK.key, | ||||
|                     IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.RENDERER_ASYNCHRONOUS_SHADERS, | ||||
|                     R.string.renderer_asynchronous_shaders, | ||||
|                     R.string.renderer_asynchronous_shaders_description, | ||||
|                     IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key, | ||||
|                     IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun addAudioSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio)) | ||||
|         sl.add( | ||||
|             SliderSetting( | ||||
|                 IntSetting.AUDIO_VOLUME, | ||||
|                 R.string.audio_volume, | ||||
|                 R.string.audio_volume_description, | ||||
|                 0, | ||||
|                 100, | ||||
|                 "%", | ||||
|                 IntSetting.AUDIO_VOLUME.key, | ||||
|                 IntSetting.AUDIO_VOLUME.defaultValue | ||||
|             ) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun addThemeSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme)) | ||||
|         sl.apply { | ||||
|             val theme: AbstractIntSetting = object : AbstractIntSetting { | ||||
|                 override var int: Int | ||||
|                     get() = preferences.getInt(Settings.PREF_THEME, 0) | ||||
|                     set(value) { | ||||
|                         preferences.edit() | ||||
|                             .putInt(Settings.PREF_THEME, value) | ||||
|                             .apply() | ||||
|                         settingsActivity.recreate() | ||||
|                     } | ||||
|                 override val key: String? = null | ||||
|                 override val section: String? = null | ||||
|                 override val isRuntimeEditable: Boolean = false | ||||
|                 override val valueAsString: String | ||||
|                     get() = preferences.getInt(Settings.PREF_THEME, 0).toString() | ||||
|                 override val defaultValue: Any = 0 | ||||
|             } | ||||
|  | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|                 add( | ||||
|                     SingleChoiceSetting( | ||||
|                         theme, | ||||
|                         R.string.change_app_theme, | ||||
|                         0, | ||||
|                         R.array.themeEntriesA12, | ||||
|                         R.array.themeValuesA12 | ||||
|                     ) | ||||
|                 ) | ||||
|             } else { | ||||
|                 add( | ||||
|                     SingleChoiceSetting( | ||||
|                         theme, | ||||
|                         R.string.change_app_theme, | ||||
|                         0, | ||||
|                         R.array.themeEntries, | ||||
|                         R.array.themeValues | ||||
|                     ) | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             val themeMode: AbstractIntSetting = object : AbstractIntSetting { | ||||
|                 override var int: Int | ||||
|                     get() = preferences.getInt(Settings.PREF_THEME_MODE, -1) | ||||
|                     set(value) { | ||||
|                         preferences.edit() | ||||
|                             .putInt(Settings.PREF_THEME_MODE, value) | ||||
|                             .apply() | ||||
|                         ThemeHelper.setThemeMode(settingsActivity) | ||||
|                     } | ||||
|                 override val key: String? = null | ||||
|                 override val section: String? = null | ||||
|                 override val isRuntimeEditable: Boolean = false | ||||
|                 override val valueAsString: String | ||||
|                     get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString() | ||||
|                 override val defaultValue: Any = -1 | ||||
|             } | ||||
|  | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     themeMode, | ||||
|                     R.string.change_theme_mode, | ||||
|                     0, | ||||
|                     R.array.themeModeEntries, | ||||
|                     R.array.themeModeValues | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|             val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting { | ||||
|                 override var boolean: Boolean | ||||
|                     get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) | ||||
|                     set(value) { | ||||
|                         preferences.edit() | ||||
|                             .putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value) | ||||
|                             .apply() | ||||
|                         settingsActivity.recreate() | ||||
|                     } | ||||
|                 override val key: String? = null | ||||
|                 override val section: String? = null | ||||
|                 override val isRuntimeEditable: Boolean = false | ||||
|                 override val valueAsString: String | ||||
|                     get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false) | ||||
|                         .toString() | ||||
|                 override val defaultValue: Any = false | ||||
|             } | ||||
|  | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     blackBackgrounds, | ||||
|                     R.string.use_black_backgrounds, | ||||
|                     R.string.use_black_backgrounds_description | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun addDebugSettings(sl: ArrayList<SettingsItem>) { | ||||
|         settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_debug)) | ||||
|         sl.apply { | ||||
|             add( | ||||
|                 SingleChoiceSetting( | ||||
|                     IntSetting.RENDERER_BACKEND, | ||||
|                     R.string.renderer_api, | ||||
|                     0, | ||||
|                     R.array.rendererApiNames, | ||||
|                     R.array.rendererApiValues, | ||||
|                     IntSetting.RENDERER_BACKEND.key, | ||||
|                     IntSetting.RENDERER_BACKEND.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SwitchSetting( | ||||
|                     IntSetting.RENDERER_DEBUG, | ||||
|                     R.string.renderer_debug, | ||||
|                     R.string.renderer_debug_description, | ||||
|                     IntSetting.RENDERER_DEBUG.key, | ||||
|                     IntSetting.RENDERER_DEBUG.defaultValue | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.ui | ||||
|  | ||||
| import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem | ||||
|  | ||||
| /** | ||||
|  * Abstraction for a screen showing a list of settings. Instances of | ||||
|  * this type of view will each display a layer of the setting hierarchy. | ||||
|  */ | ||||
| interface SettingsFragmentView { | ||||
|     /** | ||||
|      * Pass an ArrayList to the View so that it can be displayed on screen. | ||||
|      * | ||||
|      * @param settingsList The result of converting the HashMap to an ArrayList | ||||
|      */ | ||||
|     fun showSettingsList(settingsList: ArrayList<SettingsItem>) | ||||
|  | ||||
|     /** | ||||
|      * Instructs the Fragment to load the settings screen. | ||||
|      */ | ||||
|     fun loadSettingsList() | ||||
|  | ||||
|     /** | ||||
|      * @return The Fragment's containing activity. | ||||
|      */ | ||||
|     val activityView: SettingsActivityView? | ||||
|  | ||||
|     /** | ||||
|      * Tell the Fragment to tell the containing Activity to show a new | ||||
|      * Fragment containing a submenu of settings. | ||||
|      * | ||||
|      * @param menuKey Identifier for the settings group that should be shown. | ||||
|      */ | ||||
|     fun loadSubMenu(menuKey: String) | ||||
|  | ||||
|     /** | ||||
|      * Tell the Fragment to tell the containing activity to display a toast message. | ||||
|      * | ||||
|      * @param message Text to be shown in the Toast | ||||
|      * @param is_long Whether this should be a long Toast or short one. | ||||
|      */ | ||||
|     fun showToastMessage(message: String?, is_long: Boolean) | ||||
|  | ||||
|     /** | ||||
|      * Have the fragment add a setting to the HashMap. | ||||
|      * | ||||
|      * @param setting The (possibly previously missing) new setting. | ||||
|      */ | ||||
|     fun putSetting(setting: AbstractSetting) | ||||
|  | ||||
|     /** | ||||
|      * Have the fragment tell the containing Activity that a setting was modified. | ||||
|      */ | ||||
|     fun onSettingChanged() | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,241 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.features.settings.utils | ||||
|  | ||||
| import org.ini4j.Wini | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.features.settings.model.* | ||||
| import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap | ||||
| import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView | ||||
| import org.yuzu.yuzu_emu.utils.BiMap | ||||
| import org.yuzu.yuzu_emu.utils.DirectoryInitialization | ||||
| import org.yuzu.yuzu_emu.utils.Log | ||||
| import java.io.* | ||||
| import java.util.* | ||||
|  | ||||
| /** | ||||
|  * Contains static methods for interacting with .ini files in which settings are stored. | ||||
|  */ | ||||
| object SettingsFile { | ||||
|     const val FILE_NAME_CONFIG = "config" | ||||
|  | ||||
|     private var sectionsMap = BiMap<String?, String?>() | ||||
|  | ||||
|     /** | ||||
|      * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves | ||||
|      * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it | ||||
|      * failed. | ||||
|      * | ||||
|      * @param ini          The ini file to load the settings from | ||||
|      * @param isCustomGame | ||||
|      * @param view         The current view. | ||||
|      * @return An Observable that emits a HashMap of the file's contents, then completes. | ||||
|      */ | ||||
|     private fun readFile( | ||||
|         ini: File?, | ||||
|         isCustomGame: Boolean, | ||||
|         view: SettingsActivityView? = null | ||||
|     ): HashMap<String, SettingSection?> { | ||||
|         val sections: HashMap<String, SettingSection?> = SettingsSectionMap() | ||||
|         var reader: BufferedReader? = null | ||||
|         try { | ||||
|             reader = BufferedReader(FileReader(ini)) | ||||
|             var current: SettingSection? = null | ||||
|             var line: String? | ||||
|             while (reader.readLine().also { line = it } != null) { | ||||
|                 if (line!!.startsWith("[") && line!!.endsWith("]")) { | ||||
|                     current = sectionFromLine(line!!, isCustomGame) | ||||
|                     sections[current.name] = current | ||||
|                 } else if (current != null) { | ||||
|                     val setting = settingFromLine(line!!) | ||||
|                     if (setting != null) { | ||||
|                         current.putSetting(setting) | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } catch (e: FileNotFoundException) { | ||||
|             Log.error("[SettingsFile] File not found: " + e.message) | ||||
|             view?.onSettingsFileNotFound() | ||||
|         } catch (e: IOException) { | ||||
|             Log.error("[SettingsFile] Error reading from: " + e.message) | ||||
|             view?.onSettingsFileNotFound() | ||||
|         } finally { | ||||
|             if (reader != null) { | ||||
|                 try { | ||||
|                     reader.close() | ||||
|                 } catch (e: IOException) { | ||||
|                     Log.error("[SettingsFile] Error closing: " + e.message) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return sections | ||||
|     } | ||||
|  | ||||
|     fun readFile(fileName: String, view: SettingsActivityView?): HashMap<String, SettingSection?> { | ||||
|         return readFile(getSettingsFile(fileName), false, view) | ||||
|     } | ||||
|  | ||||
|     fun readFile(fileName: String): HashMap<String, SettingSection?> = | ||||
|         readFile(getSettingsFile(fileName), false) | ||||
|  | ||||
|     /** | ||||
|      * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves | ||||
|      * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it | ||||
|      * failed. | ||||
|      * | ||||
|      * @param gameId the id of the game to load it's settings. | ||||
|      * @param view   The current view. | ||||
|      */ | ||||
|     fun readCustomGameSettings( | ||||
|         gameId: String, | ||||
|         view: SettingsActivityView? | ||||
|     ): HashMap<String, SettingSection?> { | ||||
|         return readFile(getCustomGameSettingsFile(gameId), true, view) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error | ||||
|      * telling why it failed. | ||||
|      * | ||||
|      * @param fileName The target filename without a path or extension. | ||||
|      * @param sections The HashMap containing the Settings we want to serialize. | ||||
|      * @param view     The current view. | ||||
|      */ | ||||
|     fun saveFile( | ||||
|         fileName: String, | ||||
|         sections: TreeMap<String, SettingSection>, | ||||
|         view: SettingsActivityView | ||||
|     ) { | ||||
|         val ini = getSettingsFile(fileName) | ||||
|         try { | ||||
|             val writer = Wini(ini) | ||||
|             val keySet: Set<String> = sections.keys | ||||
|             for (key in keySet) { | ||||
|                 val section = sections[key] | ||||
|                 writeSection(writer, section!!) | ||||
|             } | ||||
|             writer.store() | ||||
|         } catch (e: IOException) { | ||||
|             Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message) | ||||
|             view.showToastMessage( | ||||
|                 YuzuApplication.appContext | ||||
|                     .getString(R.string.error_saving, fileName, e.message), | ||||
|                 false | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun saveCustomGameSettings(gameId: String?, sections: HashMap<String, SettingSection?>) { | ||||
|         val sortedSections: Set<String> = TreeSet(sections.keys) | ||||
|         for (sectionKey in sortedSections) { | ||||
|             val section = sections[sectionKey] | ||||
|             val settings = section!!.settings | ||||
|             val sortedKeySet: Set<String> = TreeSet(settings.keys) | ||||
|             for (settingKey in sortedKeySet) { | ||||
|                 val setting = settings[settingKey] | ||||
|                 NativeLibrary.setUserSetting( | ||||
|                     gameId, mapSectionNameFromIni( | ||||
|                         section.name | ||||
|                     ), setting!!.key, setting.valueAsString | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun mapSectionNameFromIni(generalSectionName: String): String? { | ||||
|         return if (sectionsMap.getForward(generalSectionName) != null) { | ||||
|             sectionsMap.getForward(generalSectionName) | ||||
|         } else generalSectionName | ||||
|     } | ||||
|  | ||||
|     private fun mapSectionNameToIni(generalSectionName: String): String { | ||||
|         return if (sectionsMap.getBackward(generalSectionName) != null) { | ||||
|             sectionsMap.getBackward(generalSectionName).toString() | ||||
|         } else generalSectionName | ||||
|     } | ||||
|  | ||||
|     fun getSettingsFile(fileName: String): File { | ||||
|         return File( | ||||
|             DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini" | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun getCustomGameSettingsFile(gameId: String): File { | ||||
|         return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini") | ||||
|     } | ||||
|  | ||||
|     private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection { | ||||
|         var sectionName: String = line.substring(1, line.length - 1) | ||||
|         if (isCustomGame) { | ||||
|             sectionName = mapSectionNameToIni(sectionName) | ||||
|         } | ||||
|         return SettingSection(sectionName) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * For a line of text, determines what type of data is being represented, and returns | ||||
|      * a Setting object containing this data. | ||||
|      * | ||||
|      * @param line    The line of text being parsed. | ||||
|      * @return A typed Setting containing the key/value contained in the line. | ||||
|      */ | ||||
|     private fun settingFromLine(line: String): AbstractSetting? { | ||||
|         val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() | ||||
|         if (splitLine.size != 2) { | ||||
|             return null | ||||
|         } | ||||
|         val key = splitLine[0].trim { it <= ' ' } | ||||
|         val value = splitLine[1].trim { it <= ' ' } | ||||
|         if (value.isEmpty()) { | ||||
|             return null | ||||
|         } | ||||
|  | ||||
|         val booleanSetting = BooleanSetting.from(key) | ||||
|         if (booleanSetting != null) { | ||||
|             booleanSetting.boolean = value.toBoolean() | ||||
|             return booleanSetting | ||||
|         } | ||||
|  | ||||
|         val intSetting = IntSetting.from(key) | ||||
|         if (intSetting != null) { | ||||
|             intSetting.int = value.toInt() | ||||
|             return intSetting | ||||
|         } | ||||
|  | ||||
|         val floatSetting = FloatSetting.from(key) | ||||
|         if (floatSetting != null) { | ||||
|             floatSetting.float = value.toFloat() | ||||
|             return floatSetting | ||||
|         } | ||||
|  | ||||
|         val stringSetting = StringSetting.from(key) | ||||
|         if (stringSetting != null) { | ||||
|             stringSetting.string = value | ||||
|             return stringSetting | ||||
|         } | ||||
|  | ||||
|         return null | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Writes the contents of a Section HashMap to disk. | ||||
|      * | ||||
|      * @param parser  A Wini pointed at a file on disk. | ||||
|      * @param section A section containing settings to be written to the file. | ||||
|      */ | ||||
|     private fun writeSection(parser: Wini, section: SettingSection) { | ||||
|         // Write the section header. | ||||
|         val header = section.name | ||||
|  | ||||
|         // Write this section's values. | ||||
|         val settings = section.settings | ||||
|         val keySet: Set<String> = settings.keys | ||||
|         for (key in keySet) { | ||||
|             val setting = settings[key] | ||||
|             parser.put(header, setting!!.key, setting.valueAsString) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
| } | ||||
| @@ -0,0 +1,613 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.app.AlertDialog | ||||
| import android.content.Context | ||||
| import android.content.DialogInterface | ||||
| import android.content.SharedPreferences | ||||
| import android.content.pm.ActivityInfo | ||||
| import android.content.res.Resources | ||||
| import android.graphics.Color | ||||
| import android.os.Bundle | ||||
| import android.os.Handler | ||||
| import android.os.Looper | ||||
| import android.util.Rational | ||||
| import android.util.TypedValue | ||||
| import android.view.* | ||||
| import android.widget.TextView | ||||
| import androidx.activity.OnBackPressedCallback | ||||
| import androidx.appcompat.widget.PopupMenu | ||||
| import androidx.core.content.res.ResourcesCompat | ||||
| import androidx.core.graphics.Insets | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.updatePadding | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.preference.PreferenceManager | ||||
| import androidx.window.layout.FoldingFeature | ||||
| import androidx.window.layout.WindowLayoutInfo | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import com.google.android.material.slider.Slider | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.activities.EmulationActivity | ||||
| import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding | ||||
| import org.yuzu.yuzu_emu.features.settings.model.IntSetting | ||||
| import org.yuzu.yuzu_emu.features.settings.model.Settings | ||||
| import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity | ||||
| import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile | ||||
| import org.yuzu.yuzu_emu.model.Game | ||||
| import org.yuzu.yuzu_emu.utils.* | ||||
| import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable | ||||
|  | ||||
| class EmulationFragment : Fragment(), SurfaceHolder.Callback { | ||||
|     private lateinit var preferences: SharedPreferences | ||||
|     private lateinit var emulationState: EmulationState | ||||
|     private var emulationActivity: EmulationActivity? = null | ||||
|     private var perfStatsUpdater: (() -> Unit)? = null | ||||
|  | ||||
|     private var _binding: FragmentEmulationBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     private lateinit var game: Game | ||||
|  | ||||
|     override fun onAttach(context: Context) { | ||||
|         super.onAttach(context) | ||||
|         if (context is EmulationActivity) { | ||||
|             emulationActivity = context | ||||
|             NativeLibrary.setEmulationActivity(context) | ||||
|         } else { | ||||
|             throw IllegalStateException("EmulationFragment must have EmulationActivity parent") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initialize anything that doesn't depend on the layout / views in here. | ||||
|      */ | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|  | ||||
|         // So this fragment doesn't restart on configuration changes; i.e. rotation. | ||||
|         retainInstance = true | ||||
|         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|         game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!! | ||||
|         emulationState = EmulationState(game.path) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Initialize the UI and start emulation in here. | ||||
|      */ | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentEmulationBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         binding.surfaceEmulation.holder.addCallback(this) | ||||
|         binding.showFpsText.setTextColor(Color.YELLOW) | ||||
|         binding.doneControlConfig.setOnClickListener { stopConfiguringControls() } | ||||
|  | ||||
|         // Setup overlay. | ||||
|         updateShowFpsOverlay() | ||||
|  | ||||
|         binding.inGameMenu.getHeaderView(0).findViewById<TextView>(R.id.text_game_title).text = | ||||
|             game.title | ||||
|         binding.inGameMenu.setNavigationItemSelectedListener { | ||||
|             when (it.itemId) { | ||||
|                 R.id.menu_pause_emulation -> { | ||||
|                     if (emulationState.isPaused) { | ||||
|                         emulationState.run(false) | ||||
|                         it.title = resources.getString(R.string.emulation_pause) | ||||
|                         it.icon = ResourcesCompat.getDrawable( | ||||
|                             resources, | ||||
|                             R.drawable.ic_pause, | ||||
|                             requireContext().theme | ||||
|                         ) | ||||
|                     } else { | ||||
|                         emulationState.pause() | ||||
|                         it.title = resources.getString(R.string.emulation_unpause) | ||||
|                         it.icon = ResourcesCompat.getDrawable( | ||||
|                             resources, | ||||
|                             R.drawable.ic_play, | ||||
|                             requireContext().theme | ||||
|                         ) | ||||
|                     } | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_settings -> { | ||||
|                     SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_overlay_controls -> { | ||||
|                     showOverlayOptions() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_exit -> { | ||||
|                     emulationState.stop() | ||||
|                     requireActivity().finish() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 else -> true | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         setInsets() | ||||
|  | ||||
|         requireActivity().onBackPressedDispatcher.addCallback( | ||||
|             requireActivity(), | ||||
|             object : OnBackPressedCallback(true) { | ||||
|                 override fun handleOnBackPressed() { | ||||
|                     if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open() | ||||
|                 } | ||||
|             }) | ||||
|     } | ||||
|  | ||||
|     override fun onResume() { | ||||
|         super.onResume() | ||||
|         if (!DirectoryInitialization.areDirectoriesReady) { | ||||
|             DirectoryInitialization.start(requireContext()) | ||||
|         } | ||||
|  | ||||
|         binding.surfaceEmulation.setAspectRatio( | ||||
|             when (IntSetting.RENDERER_ASPECT_RATIO.int) { | ||||
|                 0 -> Rational(16, 9) | ||||
|                 1 -> Rational(4, 3) | ||||
|                 2 -> Rational(21, 9) | ||||
|                 3 -> Rational(16, 10) | ||||
|                 4 -> null // Stretch | ||||
|                 else -> Rational(16, 9) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         emulationState.run(emulationActivity!!.isActivityRecreated) | ||||
|     } | ||||
|  | ||||
|     override fun onPause() { | ||||
|         if (emulationState.isRunning) { | ||||
|             emulationState.pause() | ||||
|         } | ||||
|         super.onPause() | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         super.onDestroyView() | ||||
|         _binding = null | ||||
|     } | ||||
|  | ||||
|     override fun onDetach() { | ||||
|         NativeLibrary.clearEmulationActivity() | ||||
|         super.onDetach() | ||||
|     } | ||||
|  | ||||
|     private fun refreshInputOverlay() { | ||||
|         binding.surfaceInputOverlay.refreshControls() | ||||
|     } | ||||
|  | ||||
|     private fun resetInputOverlay() { | ||||
|         preferences.edit() | ||||
|             .remove(Settings.PREF_CONTROL_SCALE) | ||||
|             .remove(Settings.PREF_CONTROL_OPACITY) | ||||
|             .apply() | ||||
|         binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() } | ||||
|     } | ||||
|  | ||||
|     private fun updateShowFpsOverlay() { | ||||
|         if (EmulationMenuSettings.showFps) { | ||||
|             val SYSTEM_FPS = 0 | ||||
|             val FPS = 1 | ||||
|             val FRAMETIME = 2 | ||||
|             val SPEED = 3 | ||||
|             perfStatsUpdater = { | ||||
|                 val perfStats = NativeLibrary.getPerfStats() | ||||
|                 if (perfStats[FPS] > 0 && _binding != null) { | ||||
|                     binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS]) | ||||
|                 } | ||||
|  | ||||
|                 if (!emulationState.isStopped) { | ||||
|                     perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100) | ||||
|                 } | ||||
|             } | ||||
|             perfStatsUpdateHandler.post(perfStatsUpdater!!) | ||||
|             binding.showFpsText.text = resources.getString(R.string.emulation_game_loading) | ||||
|             binding.showFpsText.visibility = View.VISIBLE | ||||
|         } else { | ||||
|             if (perfStatsUpdater != null) { | ||||
|                 perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!) | ||||
|             } | ||||
|             binding.showFpsText.visibility = View.GONE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private val Number.toPx get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics).toInt() | ||||
|  | ||||
|     fun updateCurrentLayout(emulationActivity: EmulationActivity, newLayoutInfo: WindowLayoutInfo) { | ||||
|         val isFolding = (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let { | ||||
|             if (it.isSeparating) { | ||||
|                 emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED | ||||
|                 if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) { | ||||
|                     binding.surfaceEmulation.layoutParams.height = it.bounds.top | ||||
|                     binding.inGameMenu.layoutParams.height = it.bounds.bottom | ||||
|                     binding.overlayContainer.layoutParams.height = it.bounds.bottom - 48.toPx | ||||
|                     binding.overlayContainer.updatePadding(0, 0, 0, 24.toPx) | ||||
|                 } | ||||
|             } | ||||
|             it.isSeparating | ||||
|         } ?: false | ||||
|         if (!isFolding) { | ||||
|             binding.surfaceEmulation.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT | ||||
|             binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT | ||||
|             binding.overlayContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT | ||||
|             binding.overlayContainer.updatePadding(0, 0, 0, 0) | ||||
|             emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE | ||||
|         } | ||||
|         binding.surfaceInputOverlay.requestLayout() | ||||
|         binding.inGameMenu.requestLayout() | ||||
|         binding.overlayContainer.requestLayout() | ||||
|     } | ||||
|  | ||||
|     override fun surfaceCreated(holder: SurfaceHolder) { | ||||
|         // We purposely don't do anything here. | ||||
|         // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. | ||||
|     } | ||||
|  | ||||
|     override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { | ||||
|         Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height) | ||||
|         emulationState.newSurface(holder.surface) | ||||
|     } | ||||
|  | ||||
|     override fun surfaceDestroyed(holder: SurfaceHolder) { | ||||
|         emulationState.clearSurface() | ||||
|     } | ||||
|  | ||||
|     private fun showOverlayOptions() { | ||||
|         val anchor = binding.inGameMenu.findViewById<View>(R.id.menu_overlay_controls) | ||||
|         val popup = PopupMenu(requireContext(), anchor) | ||||
|  | ||||
|         popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu) | ||||
|  | ||||
|         popup.menu.apply { | ||||
|             findItem(R.id.menu_toggle_fps).isChecked = EmulationMenuSettings.showFps | ||||
|             findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter | ||||
|             findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide | ||||
|             findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay | ||||
|             findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback | ||||
|         } | ||||
|  | ||||
|         popup.setOnMenuItemClickListener { | ||||
|             when (it.itemId) { | ||||
|                 R.id.menu_toggle_fps -> { | ||||
|                     it.isChecked = !it.isChecked | ||||
|                     EmulationMenuSettings.showFps = it.isChecked | ||||
|                     updateShowFpsOverlay() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_edit_overlay -> { | ||||
|                     binding.drawerLayout.close() | ||||
|                     binding.surfaceInputOverlay.requestFocus() | ||||
|                     startConfiguringControls() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_adjust_overlay -> { | ||||
|                     adjustOverlay() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_toggle_controls -> { | ||||
|                     val preferences = | ||||
|                         PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|                     val optionsArray = BooleanArray(15) | ||||
|                     for (i in 0..14) { | ||||
|                         optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13) | ||||
|                     } | ||||
|  | ||||
|                     val dialog = MaterialAlertDialogBuilder(requireContext()) | ||||
|                         .setTitle(R.string.emulation_toggle_controls) | ||||
|                         .setMultiChoiceItems( | ||||
|                             R.array.gamepadButtons, | ||||
|                             optionsArray | ||||
|                         ) { _, indexSelected, isChecked -> | ||||
|                             preferences.edit() | ||||
|                                 .putBoolean("buttonToggle$indexSelected", isChecked) | ||||
|                                 .apply() | ||||
|                         } | ||||
|                         .setPositiveButton(android.R.string.ok) { _, _ -> | ||||
|                             refreshInputOverlay() | ||||
|                         } | ||||
|                         .setNegativeButton(android.R.string.cancel, null) | ||||
|                         .setNeutralButton(R.string.emulation_toggle_all) { _, _ -> } | ||||
|                         .show() | ||||
|  | ||||
|                     // Override normal behaviour so the dialog doesn't close | ||||
|                     dialog.getButton(AlertDialog.BUTTON_NEUTRAL) | ||||
|                         .setOnClickListener { | ||||
|                             val isChecked = !optionsArray[0] | ||||
|                             for (i in 0..14) { | ||||
|                                 optionsArray[i] = isChecked | ||||
|                                 dialog.listView.setItemChecked(i, isChecked) | ||||
|                                 preferences.edit() | ||||
|                                     .putBoolean("buttonToggle$i", isChecked) | ||||
|                                     .apply() | ||||
|                             } | ||||
|                         } | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_show_overlay -> { | ||||
|                     it.isChecked = !it.isChecked | ||||
|                     EmulationMenuSettings.showOverlay = it.isChecked | ||||
|                     refreshInputOverlay() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_rel_stick_center -> { | ||||
|                     it.isChecked = !it.isChecked | ||||
|                     EmulationMenuSettings.joystickRelCenter = it.isChecked | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_dpad_slide -> { | ||||
|                     it.isChecked = !it.isChecked | ||||
|                     EmulationMenuSettings.dpadSlide = it.isChecked | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_haptics -> { | ||||
|                     it.isChecked = !it.isChecked | ||||
|                     EmulationMenuSettings.hapticFeedback = it.isChecked | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 R.id.menu_reset_overlay -> { | ||||
|                     binding.drawerLayout.close() | ||||
|                     resetInputOverlay() | ||||
|                     true | ||||
|                 } | ||||
|  | ||||
|                 else -> true | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         popup.show() | ||||
|     } | ||||
|  | ||||
|     private fun startConfiguringControls() { | ||||
|         binding.doneControlConfig.visibility = View.VISIBLE | ||||
|         binding.surfaceInputOverlay.setIsInEditMode(true) | ||||
|     } | ||||
|  | ||||
|     private fun stopConfiguringControls() { | ||||
|         binding.doneControlConfig.visibility = View.GONE | ||||
|         binding.surfaceInputOverlay.setIsInEditMode(false) | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetTextI18n") | ||||
|     private fun adjustOverlay() { | ||||
|         val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater) | ||||
|         adjustBinding.apply { | ||||
|             inputScaleSlider.apply { | ||||
|                 valueTo = 150F | ||||
|                 value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat() | ||||
|                 addOnChangeListener(Slider.OnChangeListener { _, value, _ -> | ||||
|                     inputScaleValue.text = "${value.toInt()}%" | ||||
|                     setControlScale(value.toInt()) | ||||
|                 }) | ||||
|             } | ||||
|             inputOpacitySlider.apply { | ||||
|                 valueTo = 100F | ||||
|                 value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).toFloat() | ||||
|                 addOnChangeListener(Slider.OnChangeListener { _, value, _ -> | ||||
|                     inputOpacityValue.text = "${value.toInt()}%" | ||||
|                     setControlOpacity(value.toInt()) | ||||
|                 }) | ||||
|             } | ||||
|             inputScaleValue.text = "${inputScaleSlider.value.toInt()}%" | ||||
|             inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%" | ||||
|         } | ||||
|  | ||||
|         MaterialAlertDialogBuilder(requireContext()) | ||||
|             .setTitle(R.string.emulation_control_adjust) | ||||
|             .setView(adjustBinding.root) | ||||
|             .setPositiveButton(android.R.string.ok, null) | ||||
|             .setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int -> | ||||
|                 setControlScale(50) | ||||
|                 setControlOpacity(100) | ||||
|             } | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     private fun setControlScale(scale: Int) { | ||||
|         preferences.edit() | ||||
|             .putInt(Settings.PREF_CONTROL_SCALE, scale) | ||||
|             .apply() | ||||
|         refreshInputOverlay() | ||||
|     } | ||||
|  | ||||
|     private fun setControlOpacity(opacity: Int) { | ||||
|         preferences.edit() | ||||
|             .putInt(Settings.PREF_CONTROL_OPACITY, opacity) | ||||
|             .apply() | ||||
|         refreshInputOverlay() | ||||
|     } | ||||
|  | ||||
|     private fun setInsets() { | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.inGameMenu) { v: View, windowInsets: WindowInsetsCompat -> | ||||
|             val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) | ||||
|             var left = 0 | ||||
|             var right = 0 | ||||
|             if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) { | ||||
|                 left = cutInsets.left | ||||
|             } else { | ||||
|                 right = cutInsets.right | ||||
|             } | ||||
|  | ||||
|             v.setPadding(left, cutInsets.top, right, 0) | ||||
|  | ||||
|             // Ensure FPS text doesn't get cut off by rounded display corners | ||||
|             val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge) | ||||
|             if (cutInsets.left == 0) { | ||||
|                 binding.showFpsText.setPadding( | ||||
|                     sidePadding, | ||||
|                     cutInsets.top, | ||||
|                     cutInsets.right, | ||||
|                     cutInsets.bottom | ||||
|                 ) | ||||
|             } else { | ||||
|                 binding.showFpsText.setPadding( | ||||
|                     cutInsets.left, | ||||
|                     cutInsets.top, | ||||
|                     cutInsets.right, | ||||
|                     cutInsets.bottom | ||||
|                 ) | ||||
|             } | ||||
|             windowInsets | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private class EmulationState(private val gamePath: String) { | ||||
|         private var state: State | ||||
|         private var surface: Surface? = null | ||||
|         private var runWhenSurfaceIsValid = false | ||||
|  | ||||
|         init { | ||||
|             // Starting state is stopped. | ||||
|             state = State.STOPPED | ||||
|         } | ||||
|  | ||||
|         @get:Synchronized | ||||
|         val isStopped: Boolean | ||||
|             get() = state == State.STOPPED | ||||
|  | ||||
|         // Getters for the current state | ||||
|         @get:Synchronized | ||||
|         val isPaused: Boolean | ||||
|             get() = state == State.PAUSED | ||||
|  | ||||
|         @get:Synchronized | ||||
|         val isRunning: Boolean | ||||
|             get() = state == State.RUNNING | ||||
|  | ||||
|         @Synchronized | ||||
|         fun stop() { | ||||
|             if (state != State.STOPPED) { | ||||
|                 Log.debug("[EmulationFragment] Stopping emulation.") | ||||
|                 NativeLibrary.stopEmulation() | ||||
|                 state = State.STOPPED | ||||
|             } else { | ||||
|                 Log.warning("[EmulationFragment] Stop called while already stopped.") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // State changing methods | ||||
|         @Synchronized | ||||
|         fun pause() { | ||||
|             if (state != State.PAUSED) { | ||||
|                 Log.debug("[EmulationFragment] Pausing emulation.") | ||||
|  | ||||
|                 NativeLibrary.pauseEmulation() | ||||
|  | ||||
|                 state = State.PAUSED | ||||
|             } else { | ||||
|                 Log.warning("[EmulationFragment] Pause called while already paused.") | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @Synchronized | ||||
|         fun run(isActivityRecreated: Boolean) { | ||||
|             if (isActivityRecreated) { | ||||
|                 if (NativeLibrary.isRunning()) { | ||||
|                     state = State.PAUSED | ||||
|                 } | ||||
|             } else { | ||||
|                 Log.debug("[EmulationFragment] activity resumed or fresh start") | ||||
|             } | ||||
|  | ||||
|             // If the surface is set, run now. Otherwise, wait for it to get set. | ||||
|             if (surface != null) { | ||||
|                 runWithValidSurface() | ||||
|             } else { | ||||
|                 runWhenSurfaceIsValid = true | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Surface callbacks | ||||
|         @Synchronized | ||||
|         fun newSurface(surface: Surface?) { | ||||
|             this.surface = surface | ||||
|             if (runWhenSurfaceIsValid) { | ||||
|                 runWithValidSurface() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         @Synchronized | ||||
|         fun clearSurface() { | ||||
|             if (surface == null) { | ||||
|                 Log.warning("[EmulationFragment] clearSurface called, but surface already null.") | ||||
|             } else { | ||||
|                 surface = null | ||||
|                 Log.debug("[EmulationFragment] Surface destroyed.") | ||||
|                 when (state) { | ||||
|                     State.RUNNING -> { | ||||
|                         state = State.PAUSED | ||||
|                     } | ||||
|  | ||||
|                     State.PAUSED -> Log.warning("[EmulationFragment] Surface cleared while emulation paused.") | ||||
|                     else -> Log.warning("[EmulationFragment] Surface cleared while emulation stopped.") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private fun runWithValidSurface() { | ||||
|             runWhenSurfaceIsValid = false | ||||
|             when (state) { | ||||
|                 State.STOPPED -> { | ||||
|                     NativeLibrary.surfaceChanged(surface) | ||||
|                     val emulationThread = Thread({ | ||||
|                         Log.debug("[EmulationFragment] Starting emulation thread.") | ||||
|                         NativeLibrary.run(gamePath) | ||||
|                     }, "NativeEmulation") | ||||
|                     emulationThread.start() | ||||
|                 } | ||||
|  | ||||
|                 State.PAUSED -> { | ||||
|                     Log.debug("[EmulationFragment] Resuming emulation.") | ||||
|                     NativeLibrary.surfaceChanged(surface) | ||||
|                     NativeLibrary.unPauseEmulation() | ||||
|                 } | ||||
|  | ||||
|                 else -> Log.debug("[EmulationFragment] Bug, run called while already running.") | ||||
|             } | ||||
|             state = State.RUNNING | ||||
|         } | ||||
|  | ||||
|         private enum class State { | ||||
|             STOPPED, RUNNING, PAUSED | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!) | ||||
|  | ||||
|         fun newInstance(game: Game): EmulationFragment { | ||||
|             val args = Bundle() | ||||
|             args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game) | ||||
|             val fragment = EmulationFragment() | ||||
|             fragment.arguments = args | ||||
|             return fragment | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,330 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.ActivityNotFoundException | ||||
| import android.content.DialogInterface | ||||
| import android.content.Intent | ||||
| import android.content.pm.PackageManager | ||||
| import android.os.Bundle | ||||
| import android.provider.DocumentsContract | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.view.ViewGroup.MarginLayoutParams | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.app.ActivityCompat | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.updatePadding | ||||
| import androidx.documentfile.provider.DocumentFile | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.navigation.fragment.findNavController | ||||
| import androidx.recyclerview.widget.LinearLayoutManager | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import com.google.android.material.transition.MaterialSharedAxis | ||||
| import org.yuzu.yuzu_emu.BuildConfig | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding | ||||
| import org.yuzu.yuzu_emu.features.DocumentProvider | ||||
| import org.yuzu.yuzu_emu.features.settings.model.Settings | ||||
| import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity | ||||
| import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile | ||||
| import org.yuzu.yuzu_emu.model.HomeSetting | ||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | ||||
| import org.yuzu.yuzu_emu.ui.main.MainActivity | ||||
| import org.yuzu.yuzu_emu.utils.FileUtil | ||||
| import org.yuzu.yuzu_emu.utils.GpuDriverHelper | ||||
|  | ||||
| class HomeSettingsFragment : Fragment() { | ||||
|     private var _binding: FragmentHomeSettingsBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     private lateinit var mainActivity: MainActivity | ||||
|  | ||||
|     private val homeViewModel: HomeViewModel by activityViewModels() | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentHomeSettingsBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         mainActivity = requireActivity() as MainActivity | ||||
|  | ||||
|         val optionsList: MutableList<HomeSetting> = mutableListOf( | ||||
|             HomeSetting( | ||||
|                 R.string.advanced_settings, | ||||
|                 R.string.settings_description, | ||||
|                 R.drawable.ic_settings | ||||
|             ) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") }, | ||||
|             HomeSetting( | ||||
|                 R.string.open_user_folder, | ||||
|                 R.string.open_user_folder_description, | ||||
|                 R.drawable.ic_folder_open | ||||
|             ) { openFileManager() }, | ||||
|             HomeSetting( | ||||
|                 R.string.preferences_theme, | ||||
|                 R.string.theme_and_color_description, | ||||
|                 R.drawable.ic_palette | ||||
|             ) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") }, | ||||
|             HomeSetting( | ||||
|                 R.string.install_gpu_driver, | ||||
|                 R.string.install_gpu_driver_description, | ||||
|                 R.drawable.ic_exit | ||||
|             ) { driverInstaller() }, | ||||
|             HomeSetting( | ||||
|                 R.string.install_amiibo_keys, | ||||
|                 R.string.install_amiibo_keys_description, | ||||
|                 R.drawable.ic_nfc | ||||
|             ) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) }, | ||||
|             HomeSetting( | ||||
|                 R.string.select_games_folder, | ||||
|                 R.string.select_games_folder_description, | ||||
|                 R.drawable.ic_add | ||||
|             ) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, | ||||
|             HomeSetting( | ||||
|                 R.string.manage_save_data, | ||||
|                 R.string.import_export_saves_description, | ||||
|                 R.drawable.ic_save | ||||
|             ) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) }, | ||||
|             HomeSetting( | ||||
|                 R.string.install_prod_keys, | ||||
|                 R.string.install_prod_keys_description, | ||||
|                 R.drawable.ic_unlock | ||||
|             ) { mainActivity.getProdKey.launch(arrayOf("*/*")) }, | ||||
|             HomeSetting( | ||||
|                 R.string.install_firmware, | ||||
|                 R.string.install_firmware_description, | ||||
|                 R.drawable.ic_firmware | ||||
|             ) { mainActivity.getFirmware.launch(arrayOf("application/zip")) }, | ||||
|             HomeSetting( | ||||
|                 R.string.share_log, | ||||
|                 R.string.share_log_description, | ||||
|                 R.drawable.ic_log | ||||
|             ) { shareLog() }, | ||||
|             HomeSetting( | ||||
|                 R.string.about, | ||||
|                 R.string.about_description, | ||||
|                 R.drawable.ic_info_outline | ||||
|             ) { | ||||
|                 exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) | ||||
|                 parentFragmentManager.primaryNavigationFragment?.findNavController() | ||||
|                     ?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment) | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|         if (!BuildConfig.PREMIUM) { | ||||
|             optionsList.add( | ||||
|                 0, | ||||
|                 HomeSetting( | ||||
|                     R.string.get_early_access, | ||||
|                     R.string.get_early_access_description, | ||||
|                     R.drawable.ic_diamond | ||||
|                 ) { | ||||
|                     exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) | ||||
|                     parentFragmentManager.primaryNavigationFragment?.findNavController() | ||||
|                         ?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment) | ||||
|                 } | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         binding.homeSettingsList.apply { | ||||
|             layoutManager = LinearLayoutManager(requireContext()) | ||||
|             adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList) | ||||
|         } | ||||
|  | ||||
|         setInsets() | ||||
|     } | ||||
|  | ||||
|     override fun onStart() { | ||||
|         super.onStart() | ||||
|         exitTransition = null | ||||
|         homeViewModel.setNavigationVisibility(visible = true, animated = true) | ||||
|         homeViewModel.setStatusBarShadeVisibility(visible = true) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         super.onDestroyView() | ||||
|         _binding = null | ||||
|     } | ||||
|  | ||||
|     private fun openFileManager() { | ||||
|         // First, try to open the user data folder directly | ||||
|         try { | ||||
|             startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW)) | ||||
|             return | ||||
|         } catch (_: ActivityNotFoundException) { | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE")) | ||||
|             return | ||||
|         } catch (_: ActivityNotFoundException) { | ||||
|         } | ||||
|  | ||||
|         // Just try to open the file manager, try the package name used on "normal" phones | ||||
|         try { | ||||
|             startActivity(getFileManagerIntent("com.google.android.documentsui")) | ||||
|             showNoLinkNotification() | ||||
|             return | ||||
|         } catch (_: ActivityNotFoundException) { | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Next, try the AOSP package name | ||||
|             startActivity(getFileManagerIntent("com.android.documentsui")) | ||||
|             showNoLinkNotification() | ||||
|             return | ||||
|         } catch (_: ActivityNotFoundException) { | ||||
|         } | ||||
|  | ||||
|         Toast.makeText( | ||||
|             requireContext(), | ||||
|             resources.getString(R.string.no_file_manager), | ||||
|             Toast.LENGTH_LONG | ||||
|         ).show() | ||||
|     } | ||||
|  | ||||
|     private fun getFileManagerIntent(packageName: String): Intent { | ||||
|         // Fragile, but some phones don't expose the system file manager in any better way | ||||
|         val intent = Intent(Intent.ACTION_MAIN) | ||||
|         intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity") | ||||
|         intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK | ||||
|         return intent | ||||
|     } | ||||
|  | ||||
|     private fun getFileManagerIntentOnDocumentProvider(action: String): Intent { | ||||
|         val authority = "${requireContext().packageName}.user" | ||||
|         val intent = Intent(action) | ||||
|         intent.addCategory(Intent.CATEGORY_DEFAULT) | ||||
|         intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID) | ||||
|         intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) | ||||
|         return intent | ||||
|     } | ||||
|  | ||||
|     private fun showNoLinkNotification() { | ||||
|         val builder = NotificationCompat.Builder( | ||||
|             requireContext(), | ||||
|             getString(R.string.notice_notification_channel_id) | ||||
|         ) | ||||
|             .setSmallIcon(R.drawable.ic_stat_notification_logo) | ||||
|             .setContentTitle(getString(R.string.notification_no_directory_link)) | ||||
|             .setContentText(getString(R.string.notification_no_directory_link_description)) | ||||
|             .setPriority(NotificationCompat.PRIORITY_HIGH) | ||||
|             .setAutoCancel(true) | ||||
|         // TODO: Make the click action for this notification lead to a help article | ||||
|  | ||||
|         with(NotificationManagerCompat.from(requireContext())) { | ||||
|             if (ActivityCompat.checkSelfPermission( | ||||
|                     requireContext(), | ||||
|                     Manifest.permission.POST_NOTIFICATIONS | ||||
|                 ) != PackageManager.PERMISSION_GRANTED | ||||
|             ) { | ||||
|                 Toast.makeText( | ||||
|                     requireContext(), | ||||
|                     resources.getString(R.string.notification_permission_not_granted), | ||||
|                     Toast.LENGTH_LONG | ||||
|                 ).show() | ||||
|                 return | ||||
|             } | ||||
|             notify(0, builder.build()) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun driverInstaller() { | ||||
|         // Get the driver name for the dialog message. | ||||
|         var driverName = GpuDriverHelper.customDriverName | ||||
|         if (driverName == null) { | ||||
|             driverName = getString(R.string.system_gpu_driver) | ||||
|         } | ||||
|  | ||||
|         MaterialAlertDialogBuilder(requireContext()) | ||||
|             .setTitle(getString(R.string.select_gpu_driver_title)) | ||||
|             .setMessage(driverName) | ||||
|             .setNegativeButton(android.R.string.cancel, null) | ||||
|             .setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int -> | ||||
|                 GpuDriverHelper.installDefaultDriver(requireContext()) | ||||
|                 Toast.makeText( | ||||
|                     requireContext(), | ||||
|                     R.string.select_gpu_driver_use_default, | ||||
|                     Toast.LENGTH_SHORT | ||||
|                 ).show() | ||||
|             } | ||||
|             .setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int -> | ||||
|                 mainActivity.getDriver.launch(arrayOf("application/zip")) | ||||
|             } | ||||
|             .show() | ||||
|     } | ||||
|  | ||||
|     private fun shareLog() { | ||||
|         val file = DocumentFile.fromSingleUri( | ||||
|             mainActivity, | ||||
|             DocumentsContract.buildDocumentUri( | ||||
|                 DocumentProvider.AUTHORITY, | ||||
|                 "${DocumentProvider.ROOT_ID}/log/yuzu_log.txt" | ||||
|             ) | ||||
|         )!! | ||||
|         if (file.exists()) { | ||||
|             val intent = Intent(Intent.ACTION_SEND) | ||||
|                 .setDataAndType(file.uri, FileUtil.TEXT_PLAIN) | ||||
|                 .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | ||||
|                 .putExtra(Intent.EXTRA_STREAM, file.uri) | ||||
|             startActivity(Intent.createChooser(intent, getText(R.string.share_log))) | ||||
|         } else { | ||||
|             Toast.makeText( | ||||
|                 requireContext(), | ||||
|                 getText(R.string.share_log_missing), | ||||
|                 Toast.LENGTH_SHORT | ||||
|             ).show() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setInsets() = | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat -> | ||||
|             val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) | ||||
|             val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) | ||||
|             val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) | ||||
|             val spacingNavigationRail = | ||||
|                 resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) | ||||
|  | ||||
|             val leftInsets = barInsets.left + cutoutInsets.left | ||||
|             val rightInsets = barInsets.right + cutoutInsets.right | ||||
|  | ||||
|             binding.scrollViewSettings.updatePadding( | ||||
|                 top = barInsets.top, | ||||
|                 bottom = barInsets.bottom | ||||
|             ) | ||||
|  | ||||
|             val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams | ||||
|             mlpScrollSettings.leftMargin = leftInsets | ||||
|             mlpScrollSettings.rightMargin = rightInsets | ||||
|             binding.scrollViewSettings.layoutParams = mlpScrollSettings | ||||
|  | ||||
|             binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation) | ||||
|  | ||||
|             if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { | ||||
|                 binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail) | ||||
|             } else { | ||||
|                 binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail) | ||||
|             } | ||||
|  | ||||
|             windowInsets | ||||
|         } | ||||
| } | ||||
| @@ -0,0 +1,210 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.app.Dialog | ||||
| import android.content.Intent | ||||
| import android.net.Uri | ||||
| import android.os.Bundle | ||||
| import android.provider.DocumentsContract | ||||
| import android.widget.Toast | ||||
| import androidx.activity.result.ActivityResultLauncher | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.documentfile.provider.DocumentFile | ||||
| import androidx.fragment.app.DialogFragment | ||||
| import com.google.android.material.dialog.MaterialAlertDialogBuilder | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.features.DocumentProvider | ||||
| import org.yuzu.yuzu_emu.getPublicFilesDir | ||||
| import org.yuzu.yuzu_emu.utils.FileUtil | ||||
| import java.io.BufferedOutputStream | ||||
| import java.io.File | ||||
| import java.io.FileOutputStream | ||||
| import java.io.FilenameFilter | ||||
| import java.time.LocalDateTime | ||||
| import java.time.format.DateTimeFormatter | ||||
| import java.util.zip.ZipEntry | ||||
| import java.util.zip.ZipOutputStream | ||||
|  | ||||
| class ImportExportSavesFragment : DialogFragment() { | ||||
|     private val context = YuzuApplication.appContext | ||||
|     private val savesFolder = | ||||
|         "${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000" | ||||
|  | ||||
|     // Get first subfolder in saves folder (should be the user folder) | ||||
|     private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: "" | ||||
|     private var lastZipCreated: File? = null | ||||
|  | ||||
|     private lateinit var startForResultExportSave: ActivityResultLauncher<Intent> | ||||
|     private lateinit var documentPicker: ActivityResultLauncher<Array<String>> | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         val activity = requireActivity() as AppCompatActivity | ||||
|  | ||||
|         val activityResultRegistry = requireActivity().activityResultRegistry | ||||
|         startForResultExportSave = activityResultRegistry.register( | ||||
|             "startForResultExportSaveKey", | ||||
|             ActivityResultContracts.StartActivityForResult() | ||||
|         ) { | ||||
|             File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively() | ||||
|         } | ||||
|         documentPicker = activityResultRegistry.register( | ||||
|             "documentPickerKey", | ||||
|             ActivityResultContracts.OpenDocument() | ||||
|         ) { | ||||
|             it?.let { uri -> importSave(uri, activity) } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { | ||||
|         return if (savesFolderRoot == "") { | ||||
|             MaterialAlertDialogBuilder(requireContext()) | ||||
|                 .setTitle(R.string.manage_save_data) | ||||
|                 .setMessage(R.string.import_export_saves_no_profile) | ||||
|                 .setPositiveButton(android.R.string.ok, null) | ||||
|                 .show() | ||||
|         } else { | ||||
|             MaterialAlertDialogBuilder(requireContext()) | ||||
|                 .setTitle(R.string.manage_save_data) | ||||
|                 .setMessage(R.string.manage_save_data_description) | ||||
|                 .setNegativeButton(R.string.export_saves) { _, _ -> | ||||
|                     exportSave() | ||||
|                 } | ||||
|                 .setPositiveButton(R.string.import_saves) { _, _ -> | ||||
|                     documentPicker.launch(arrayOf("application/zip")) | ||||
|                 } | ||||
|                 .setNeutralButton(android.R.string.cancel, null) | ||||
|                 .show() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Zips the save files located in the given folder path and creates a new zip file with the current date and time. | ||||
|      * @return true if the zip file is successfully created, false otherwise. | ||||
|      */ | ||||
|     private fun zipSave(): Boolean { | ||||
|         try { | ||||
|             val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp") | ||||
|             tempFolder.mkdirs() | ||||
|             val saveFolder = File(savesFolderRoot) | ||||
|             val outputZipFile = File( | ||||
|                 tempFolder, | ||||
|                 "yuzu saves - ${ | ||||
|                     LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) | ||||
|                 }.zip" | ||||
|             ) | ||||
|             outputZipFile.createNewFile() | ||||
|             ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos -> | ||||
|                 saveFolder.walkTopDown().forEach { file -> | ||||
|                     val zipFileName = | ||||
|                         file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/") | ||||
|                     if (zipFileName == "") | ||||
|                         return@forEach | ||||
|                     val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}") | ||||
|                     zos.putNextEntry(entry) | ||||
|                     if (file.isFile) | ||||
|                         file.inputStream().use { fis -> fis.copyTo(zos) } | ||||
|                 } | ||||
|             } | ||||
|             lastZipCreated = outputZipFile | ||||
|         } catch (e: Exception) { | ||||
|             return false | ||||
|         } | ||||
|         return true | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Exports the save file located in the given folder path by creating a zip file and sharing it via intent. | ||||
|      */ | ||||
|     private fun exportSave() { | ||||
|         CoroutineScope(Dispatchers.IO).launch { | ||||
|             val wasZipCreated = zipSave() | ||||
|             val lastZipFile = lastZipCreated | ||||
|             if (!wasZipCreated || lastZipFile == null) { | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show() | ||||
|                 } | ||||
|                 return@launch | ||||
|             } | ||||
|  | ||||
|             withContext(Dispatchers.Main) { | ||||
|                 val file = DocumentFile.fromSingleUri( | ||||
|                     context, DocumentsContract.buildDocumentUri( | ||||
|                         DocumentProvider.AUTHORITY, | ||||
|                         "${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}" | ||||
|                     ) | ||||
|                 )!! | ||||
|                 val intent = Intent(Intent.ACTION_SEND) | ||||
|                     .setDataAndType(file.uri, "application/zip") | ||||
|                     .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | ||||
|                     .putExtra(Intent.EXTRA_STREAM, file.uri) | ||||
|                 startForResultExportSave.launch(Intent.createChooser(intent, "Share save file")) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Imports the save files contained in the zip file, and replaces any existing ones with the new save file. | ||||
|      * @param zipUri The Uri of the zip file containing the save file(s) to import. | ||||
|      */ | ||||
|     private fun importSave(zipUri: Uri, activity: AppCompatActivity) { | ||||
|         val inputZip = context.contentResolver.openInputStream(zipUri) | ||||
|         // A zip needs to have at least one subfolder named after a TitleId in order to be considered valid. | ||||
|         var validZip = false | ||||
|         val savesFolder = File(savesFolderRoot) | ||||
|         val cacheSaveDir = File("${context.cacheDir.path}/saves/") | ||||
|         cacheSaveDir.mkdir() | ||||
|  | ||||
|         if (inputZip == null) { | ||||
|             Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) | ||||
|                 .show() | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val filterTitleId = | ||||
|             FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) } | ||||
|  | ||||
|         try { | ||||
|             CoroutineScope(Dispatchers.IO).launch { | ||||
|                 FileUtil.unzip(inputZip, cacheSaveDir) | ||||
|                 cacheSaveDir.list(filterTitleId)?.forEach { savePath -> | ||||
|                     File(savesFolder, savePath).deleteRecursively() | ||||
|                     File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true) | ||||
|                     validZip = true | ||||
|                 } | ||||
|  | ||||
|                 withContext(Dispatchers.Main) { | ||||
|                     if (!validZip) { | ||||
|                         MessageDialogFragment.newInstance( | ||||
|                             R.string.save_file_invalid_zip_structure, | ||||
|                             R.string.save_file_invalid_zip_structure_description | ||||
|                         ).show(activity.supportFragmentManager, MessageDialogFragment.TAG) | ||||
|                         return@withContext | ||||
|                     } | ||||
|                     Toast.makeText( | ||||
|                         context, | ||||
|                         context.getString(R.string.save_file_imported_success), | ||||
|                         Toast.LENGTH_LONG | ||||
|                     ).show() | ||||
|                 } | ||||
|  | ||||
|                 cacheSaveDir.deleteRecursively() | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG) | ||||
|                 .show() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "ImportExportSavesFragment" | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,59 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import com.google.android.material.bottomsheet.BottomSheetBehavior | ||||
| import com.google.android.material.bottomsheet.BottomSheetDialogFragment | ||||
| import org.yuzu.yuzu_emu.databinding.DialogLicenseBinding | ||||
| import org.yuzu.yuzu_emu.model.License | ||||
| import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable | ||||
|  | ||||
| class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() { | ||||
|     private var _binding: DialogLicenseBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = DialogLicenseBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         BottomSheetBehavior.from<View>(view.parent as View).state = | ||||
|             BottomSheetBehavior.STATE_HALF_EXPANDED | ||||
|  | ||||
|         val license = requireArguments().parcelable<License>(LICENSE)!! | ||||
|  | ||||
|         binding.apply { | ||||
|             textTitle.setText(license.titleId) | ||||
|             textLink.setText(license.linkId) | ||||
|             textCopyright.setText(license.copyrightId) | ||||
|             textLicense.setText(license.licenseId) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         const val TAG = "LicenseBottomSheetDialogFragment" | ||||
|  | ||||
|         const val LICENSE = "License" | ||||
|  | ||||
|         fun newInstance( | ||||
|             license: License | ||||
|         ): LicenseBottomSheetDialogFragment { | ||||
|             val dialog = LicenseBottomSheetDialogFragment() | ||||
|             val bundle = Bundle() | ||||
|             bundle.putParcelable(LICENSE, license) | ||||
|             dialog.arguments = bundle | ||||
|             return dialog | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -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" | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,236 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.content.Context | ||||
| import android.content.SharedPreferences | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.view.inputmethod.InputMethodManager | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.updatePadding | ||||
| import androidx.core.widget.doOnTextChanged | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.preference.PreferenceManager | ||||
| import info.debatty.java.stringsimilarity.Jaccard | ||||
| import info.debatty.java.stringsimilarity.JaroWinkler | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.adapters.GameAdapter | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding | ||||
| import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager | ||||
| import org.yuzu.yuzu_emu.model.Game | ||||
| import org.yuzu.yuzu_emu.model.GamesViewModel | ||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | ||||
| import org.yuzu.yuzu_emu.utils.FileUtil | ||||
| import org.yuzu.yuzu_emu.utils.Log | ||||
| import java.util.Locale | ||||
|  | ||||
| class SearchFragment : Fragment() { | ||||
|     private var _binding: FragmentSearchBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     private val gamesViewModel: GamesViewModel by activityViewModels() | ||||
|     private val homeViewModel: HomeViewModel by activityViewModels() | ||||
|  | ||||
|     private lateinit var preferences: SharedPreferences | ||||
|  | ||||
|     companion object { | ||||
|         private const val SEARCH_TEXT = "SearchText" | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentSearchBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         homeViewModel.setNavigationVisibility(visible = true, animated = false) | ||||
|         preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|  | ||||
|         if (savedInstanceState != null) { | ||||
|             binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT)) | ||||
|         } | ||||
|  | ||||
|         binding.gridGamesSearch.apply { | ||||
|             layoutManager = AutofitGridLayoutManager( | ||||
|                 requireContext(), | ||||
|                 requireContext().resources.getDimensionPixelSize(R.dimen.card_width) | ||||
|             ) | ||||
|             adapter = GameAdapter(requireActivity() as AppCompatActivity) | ||||
|         } | ||||
|  | ||||
|         binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() } | ||||
|  | ||||
|         binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int -> | ||||
|             if (text.toString().isNotEmpty()) { | ||||
|                 binding.clearButton.visibility = View.VISIBLE | ||||
|             } else { | ||||
|                 binding.clearButton.visibility = View.INVISIBLE | ||||
|             } | ||||
|             filterAndSearch() | ||||
|         } | ||||
|  | ||||
|         gamesViewModel.apply { | ||||
|             searchFocused.observe(viewLifecycleOwner) { searchFocused -> | ||||
|                 if (searchFocused) { | ||||
|                     focusSearch() | ||||
|                     gamesViewModel.setSearchFocused(false) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             games.observe(viewLifecycleOwner) { filterAndSearch() } | ||||
|             searchedGames.observe(viewLifecycleOwner) { | ||||
|                 (binding.gridGamesSearch.adapter as GameAdapter).submitList(it) | ||||
|                 if (it.isEmpty()) { | ||||
|                     binding.noResultsView.visibility = View.VISIBLE | ||||
|                 } else { | ||||
|                     binding.noResultsView.visibility = View.GONE | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         binding.clearButton.setOnClickListener { binding.searchText.setText("") } | ||||
|  | ||||
|         binding.searchBackground.setOnClickListener { focusSearch() } | ||||
|  | ||||
|         setInsets() | ||||
|         filterAndSearch() | ||||
|     } | ||||
|  | ||||
|     private inner class ScoredGame(val score: Double, val item: Game) | ||||
|  | ||||
|     private fun filterAndSearch() { | ||||
|         val baseList = gamesViewModel.games.value!! | ||||
|         val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) { | ||||
|             R.id.chip_recently_played -> { | ||||
|                 baseList.filter { | ||||
|                     val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L) | ||||
|                     lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             R.id.chip_recently_added -> { | ||||
|                 baseList.filter { | ||||
|                     val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L) | ||||
|                     addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             R.id.chip_homebrew -> { | ||||
|                 baseList.filter { | ||||
|                     Log.error("Guh - ${it.path}") | ||||
|                     FileUtil.hasExtension(it.path, "nro") | ||||
|                             || FileUtil.hasExtension(it.path, "nso") | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             R.id.chip_retail -> baseList.filter { | ||||
|                 FileUtil.hasExtension(it.path, "xci") | ||||
|                         || FileUtil.hasExtension(it.path, "nsp") | ||||
|             } | ||||
|  | ||||
|             else -> baseList | ||||
|         } | ||||
|  | ||||
|         if (binding.searchText.text.toString().isEmpty() | ||||
|             && binding.chipGroup.checkedChipId != View.NO_ID | ||||
|         ) { | ||||
|             gamesViewModel.setSearchedGames(filteredList) | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault()) | ||||
|         val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler() | ||||
|         val sortedList: List<Game> = filteredList.mapNotNull { game -> | ||||
|             val title = game.title.lowercase(Locale.getDefault()) | ||||
|             val score = searchAlgorithm.similarity(searchTerm, title) | ||||
|             if (score > 0.03) { | ||||
|                 ScoredGame(score, game) | ||||
|             } else { | ||||
|                 null | ||||
|             } | ||||
|         }.sortedByDescending { it.score }.map { it.item } | ||||
|         gamesViewModel.setSearchedGames(sortedList) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         super.onDestroyView() | ||||
|         _binding = null | ||||
|     } | ||||
|  | ||||
|     override fun onSaveInstanceState(outState: Bundle) { | ||||
|         super.onSaveInstanceState(outState) | ||||
|         if (_binding != null) { | ||||
|             outState.putString(SEARCH_TEXT, binding.searchText.text.toString()) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun focusSearch() { | ||||
|         if (_binding != null) { | ||||
|             binding.searchText.requestFocus() | ||||
|             val imm = | ||||
|                 requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? | ||||
|             imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun setInsets() = | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat -> | ||||
|             val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) | ||||
|             val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) | ||||
|             val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med) | ||||
|             val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation) | ||||
|             val spacingNavigationRail = | ||||
|                 resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail) | ||||
|             val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip) | ||||
|  | ||||
|             binding.constraintSearch.updatePadding( | ||||
|                 left = barInsets.left + cutoutInsets.left, | ||||
|                 top = barInsets.top, | ||||
|                 right = barInsets.right + cutoutInsets.right | ||||
|             ) | ||||
|  | ||||
|             binding.gridGamesSearch.updatePadding( | ||||
|                 top = extraListSpacing, | ||||
|                 bottom = barInsets.bottom + spacingNavigation + extraListSpacing | ||||
|             ) | ||||
|             binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom) | ||||
|  | ||||
|             val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams | ||||
|             if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) { | ||||
|                 binding.frameSearch.updatePadding(left = spacingNavigationRail) | ||||
|                 binding.gridGamesSearch.updatePadding(left = spacingNavigationRail) | ||||
|                 binding.noResultsView.updatePadding(left = spacingNavigationRail) | ||||
|                 binding.chipGroup.updatePadding( | ||||
|                     left = chipSpacing + spacingNavigationRail, | ||||
|                     right = chipSpacing | ||||
|                 ) | ||||
|                 mlpDivider.leftMargin = chipSpacing + spacingNavigationRail | ||||
|                 mlpDivider.rightMargin = chipSpacing | ||||
|             } else { | ||||
|                 binding.frameSearch.updatePadding(right = spacingNavigationRail) | ||||
|                 binding.gridGamesSearch.updatePadding(right = spacingNavigationRail) | ||||
|                 binding.noResultsView.updatePadding(right = spacingNavigationRail) | ||||
|                 binding.chipGroup.updatePadding( | ||||
|                     left = chipSpacing, | ||||
|                     right = chipSpacing + spacingNavigationRail | ||||
|                 ) | ||||
|                 mlpDivider.leftMargin = chipSpacing | ||||
|                 mlpDivider.rightMargin = chipSpacing + spacingNavigationRail | ||||
|             } | ||||
|             binding.divider.layoutParams = mlpDivider | ||||
|  | ||||
|             windowInsets | ||||
|         } | ||||
| } | ||||
| @@ -0,0 +1,329 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.fragments | ||||
|  | ||||
| import android.Manifest | ||||
| import android.content.Intent | ||||
| import android.os.Build | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.activity.OnBackPressedCallback | ||||
| import androidx.activity.result.contract.ActivityResultContracts | ||||
| import androidx.annotation.RequiresApi | ||||
| import androidx.appcompat.app.AppCompatActivity | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.core.view.ViewCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.isVisible | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.fragment.app.activityViewModels | ||||
| import androidx.navigation.findNavController | ||||
| import androidx.preference.PreferenceManager | ||||
| import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback | ||||
| import com.google.android.material.transition.MaterialFadeThrough | ||||
| import org.yuzu.yuzu_emu.R | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.adapters.SetupAdapter | ||||
| import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding | ||||
| import org.yuzu.yuzu_emu.features.settings.model.Settings | ||||
| import org.yuzu.yuzu_emu.model.HomeViewModel | ||||
| import org.yuzu.yuzu_emu.model.SetupPage | ||||
| import org.yuzu.yuzu_emu.ui.main.MainActivity | ||||
| import org.yuzu.yuzu_emu.utils.DirectoryInitialization | ||||
| import org.yuzu.yuzu_emu.utils.GameHelper | ||||
| import java.io.File | ||||
|  | ||||
| class SetupFragment : Fragment() { | ||||
|     private var _binding: FragmentSetupBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     private val homeViewModel: HomeViewModel by activityViewModels() | ||||
|  | ||||
|     private lateinit var mainActivity: MainActivity | ||||
|  | ||||
|     private lateinit var hasBeenWarned: BooleanArray | ||||
|  | ||||
|     companion object { | ||||
|         const val KEY_NEXT_VISIBILITY = "NextButtonVisibility" | ||||
|         const val KEY_BACK_VISIBILITY = "BackButtonVisibility" | ||||
|         const val KEY_HAS_BEEN_WARNED = "HasBeenWarned" | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         exitTransition = MaterialFadeThrough() | ||||
|     } | ||||
|  | ||||
|     override fun onCreateView( | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle? | ||||
|     ): View { | ||||
|         _binding = FragmentSetupBinding.inflate(layoutInflater) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         mainActivity = requireActivity() as MainActivity | ||||
|  | ||||
|         homeViewModel.setNavigationVisibility(visible = false, animated = false) | ||||
|  | ||||
|         requireActivity().onBackPressedDispatcher.addCallback( | ||||
|             viewLifecycleOwner, | ||||
|             object : OnBackPressedCallback(true) { | ||||
|                 override fun handleOnBackPressed() { | ||||
|                     if (binding.viewPager2.currentItem > 0) { | ||||
|                         pageBackward() | ||||
|                     } else { | ||||
|                         requireActivity().finish() | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|  | ||||
|         requireActivity().window.navigationBarColor = | ||||
|             ContextCompat.getColor(requireContext(), android.R.color.transparent) | ||||
|  | ||||
|         val pages = mutableListOf<SetupPage>() | ||||
|         pages.apply { | ||||
|             add( | ||||
|                 SetupPage( | ||||
|                     R.drawable.ic_yuzu_title, | ||||
|                     R.string.welcome, | ||||
|                     R.string.welcome_description, | ||||
|                     0, | ||||
|                     true, | ||||
|                     R.string.get_started, | ||||
|                     { pageForward() }, | ||||
|                     false | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | ||||
|                 add( | ||||
|                     SetupPage( | ||||
|                         R.drawable.ic_notification, | ||||
|                         R.string.notifications, | ||||
|                         R.string.notifications_description, | ||||
|                         0, | ||||
|                         false, | ||||
|                         R.string.give_permission, | ||||
|                         { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) }, | ||||
|                         true, | ||||
|                         R.string.notification_warning, | ||||
|                         R.string.notification_warning_description, | ||||
|                         0, | ||||
|                         { | ||||
|                             NotificationManagerCompat.from(requireContext()) | ||||
|                                 .areNotificationsEnabled() | ||||
|                         } | ||||
|                     ) | ||||
|                 ) | ||||
|             } | ||||
|  | ||||
|             add( | ||||
|                 SetupPage( | ||||
|                     R.drawable.ic_key, | ||||
|                     R.string.keys, | ||||
|                     R.string.keys_description, | ||||
|                     R.drawable.ic_add, | ||||
|                     true, | ||||
|                     R.string.select_keys, | ||||
|                     { mainActivity.getProdKey.launch(arrayOf("*/*")) }, | ||||
|                     true, | ||||
|                     R.string.install_prod_keys_warning, | ||||
|                     R.string.install_prod_keys_warning_description, | ||||
|                     R.string.install_prod_keys_warning_help, | ||||
|                     { File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() } | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SetupPage( | ||||
|                     R.drawable.ic_controller, | ||||
|                     R.string.games, | ||||
|                     R.string.games_description, | ||||
|                     R.drawable.ic_add, | ||||
|                     true, | ||||
|                     R.string.add_games, | ||||
|                     { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) }, | ||||
|                     true, | ||||
|                     R.string.add_games_warning, | ||||
|                     R.string.add_games_warning_description, | ||||
|                     R.string.add_games_warning_help, | ||||
|                     { | ||||
|                         val preferences = | ||||
|                             PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|                         preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty() | ||||
|                     } | ||||
|                 ) | ||||
|             ) | ||||
|             add( | ||||
|                 SetupPage( | ||||
|                     R.drawable.ic_check, | ||||
|                     R.string.done, | ||||
|                     R.string.done_description, | ||||
|                     R.drawable.ic_arrow_forward, | ||||
|                     false, | ||||
|                     R.string.text_continue, | ||||
|                     { finishSetup() }, | ||||
|                     false | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         binding.viewPager2.apply { | ||||
|             adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages) | ||||
|             offscreenPageLimit = 2 | ||||
|             isUserInputEnabled = false | ||||
|         } | ||||
|  | ||||
|         binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() { | ||||
|             var previousPosition: Int = 0 | ||||
|  | ||||
|             override fun onPageSelected(position: Int) { | ||||
|                 super.onPageSelected(position) | ||||
|  | ||||
|                 if (position == 1 && previousPosition == 0) { | ||||
|                     showView(binding.buttonNext) | ||||
|                     showView(binding.buttonBack) | ||||
|                 } else if (position == 0 && previousPosition == 1) { | ||||
|                     hideView(binding.buttonBack) | ||||
|                     hideView(binding.buttonNext) | ||||
|                 } else if (position == pages.size - 1 && previousPosition == pages.size - 2) { | ||||
|                     hideView(binding.buttonNext) | ||||
|                 } else if (position == pages.size - 2 && previousPosition == pages.size - 1) { | ||||
|                     showView(binding.buttonNext) | ||||
|                 } | ||||
|  | ||||
|                 previousPosition = position | ||||
|             } | ||||
|         }) | ||||
|  | ||||
|         binding.buttonNext.setOnClickListener { | ||||
|             val index = binding.viewPager2.currentItem | ||||
|             val currentPage = pages[index] | ||||
|  | ||||
|             // Checks if the user has completed the task on the current page | ||||
|             if (currentPage.hasWarning) { | ||||
|                 if (currentPage.taskCompleted.invoke()) { | ||||
|                     pageForward() | ||||
|                     return@setOnClickListener | ||||
|                 } | ||||
|  | ||||
|                 if (!hasBeenWarned[index]) { | ||||
|                     SetupWarningDialogFragment.newInstance( | ||||
|                         currentPage.warningTitleId, | ||||
|                         currentPage.warningDescriptionId, | ||||
|                         currentPage.warningHelpLinkId, | ||||
|                         index | ||||
|                     ).show(childFragmentManager, SetupWarningDialogFragment.TAG) | ||||
|                     return@setOnClickListener | ||||
|                 } | ||||
|             } | ||||
|             pageForward() | ||||
|         } | ||||
|         binding.buttonBack.setOnClickListener { pageBackward() } | ||||
|  | ||||
|         if (savedInstanceState != null) { | ||||
|             val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY) | ||||
|             val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY) | ||||
|             hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!! | ||||
|  | ||||
|             if (nextIsVisible) { | ||||
|                 binding.buttonNext.visibility = View.VISIBLE | ||||
|             } | ||||
|             if (backIsVisible) { | ||||
|                 binding.buttonBack.visibility = View.VISIBLE | ||||
|             } | ||||
|         } else { | ||||
|             hasBeenWarned = BooleanArray(pages.size) | ||||
|         } | ||||
|  | ||||
|         setInsets() | ||||
|     } | ||||
|  | ||||
|     override fun onSaveInstanceState(outState: Bundle) { | ||||
|         super.onSaveInstanceState(outState) | ||||
|         outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible) | ||||
|         outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible) | ||||
|         outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         super.onDestroyView() | ||||
|         _binding = null | ||||
|     } | ||||
|  | ||||
|     @RequiresApi(Build.VERSION_CODES.TIRAMISU) | ||||
|     private val permissionLauncher = | ||||
|         registerForActivityResult(ActivityResultContracts.RequestPermission()) { | ||||
|             if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { | ||||
|                 PermissionDeniedDialogFragment().show( | ||||
|                     childFragmentManager, | ||||
|                     PermissionDeniedDialogFragment.TAG | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|     private fun finishSetup() { | ||||
|         PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit() | ||||
|             .putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false) | ||||
|             .apply() | ||||
|         mainActivity.finishSetup(binding.root.findNavController()) | ||||
|     } | ||||
|  | ||||
|     private fun showView(view: View) { | ||||
|         view.apply { | ||||
|             alpha = 0f | ||||
|             visibility = View.VISIBLE | ||||
|             isClickable = true | ||||
|         }.animate().apply { | ||||
|             duration = 300 | ||||
|             alpha(1f) | ||||
|         }.start() | ||||
|     } | ||||
|  | ||||
|     private fun hideView(view: View) { | ||||
|         if (view.visibility == View.INVISIBLE) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         view.apply { | ||||
|             alpha = 1f | ||||
|             isClickable = false | ||||
|         }.animate().apply { | ||||
|             duration = 300 | ||||
|             alpha(0f) | ||||
|         }.withEndAction { | ||||
|             view.visibility = View.INVISIBLE | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun pageForward() { | ||||
|         binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1 | ||||
|     } | ||||
|  | ||||
|     fun pageBackward() { | ||||
|         binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1 | ||||
|     } | ||||
|  | ||||
|     fun setPageWarned(page: Int) { | ||||
|         hasBeenWarned[page] = true | ||||
|     } | ||||
|  | ||||
|     private fun setInsets() = | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat -> | ||||
|             val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) | ||||
|             val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) | ||||
|             view.setPadding( | ||||
|                 barInsets.left + cutoutInsets.left, | ||||
|                 barInsets.top + cutoutInsets.top, | ||||
|                 barInsets.right + cutoutInsets.right, | ||||
|                 barInsets.bottom + cutoutInsets.bottom | ||||
|             ) | ||||
|             windowInsets | ||||
|         } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.model | ||||
|  | ||||
| import android.os.Parcelable | ||||
| import kotlinx.parcelize.Parcelize | ||||
| import kotlinx.serialization.Serializable | ||||
| import java.util.HashSet | ||||
|  | ||||
| @Parcelize | ||||
| @Serializable | ||||
| class Game( | ||||
|     val title: String, | ||||
|     val description: String, | ||||
|     val regions: String, | ||||
|     val path: String, | ||||
|     val gameId: String, | ||||
|     val company: String | ||||
| ) : Parcelable { | ||||
|     val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime" | ||||
|     val keyLastPlayedTime get() = "${gameId}_LastPlayed" | ||||
|  | ||||
|     override fun equals(other: Any?): Boolean { | ||||
|         if (other !is Game) | ||||
|             return false | ||||
|  | ||||
|         return title == other.title | ||||
|                 && description == other.description | ||||
|                 && regions == other.regions | ||||
|                 && path == other.path | ||||
|                 && gameId == other.gameId | ||||
|                 && company == other.company | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         val extensions: Set<String> = HashSet( | ||||
|             listOf(".xci", ".nsp", ".nca", ".nro") | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,109 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.model | ||||
|  | ||||
| import android.net.Uri | ||||
| import androidx.documentfile.provider.DocumentFile | ||||
| import androidx.lifecycle.LiveData | ||||
| import androidx.lifecycle.MutableLiveData | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import androidx.preference.PreferenceManager | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import kotlinx.serialization.decodeFromString | ||||
| import kotlinx.serialization.json.Json | ||||
| import org.yuzu.yuzu_emu.NativeLibrary | ||||
| import org.yuzu.yuzu_emu.YuzuApplication | ||||
| import org.yuzu.yuzu_emu.utils.GameHelper | ||||
| import java.util.Locale | ||||
|  | ||||
| class GamesViewModel : ViewModel() { | ||||
|     private val _games = MutableLiveData<List<Game>>(emptyList()) | ||||
|     val games: LiveData<List<Game>> get() = _games | ||||
|  | ||||
|     private val _searchedGames = MutableLiveData<List<Game>>(emptyList()) | ||||
|     val searchedGames: LiveData<List<Game>> get() = _searchedGames | ||||
|  | ||||
|     private val _isReloading = MutableLiveData(false) | ||||
|     val isReloading: LiveData<Boolean> get() = _isReloading | ||||
|  | ||||
|     private val _shouldSwapData = MutableLiveData(false) | ||||
|     val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData | ||||
|  | ||||
|     private val _shouldScrollToTop = MutableLiveData(false) | ||||
|     val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop | ||||
|  | ||||
|     private val _searchFocused = MutableLiveData(false) | ||||
|     val searchFocused: LiveData<Boolean> get() = _searchFocused | ||||
|  | ||||
|     init { | ||||
|         // Ensure keys are loaded so that ROM metadata can be decrypted. | ||||
|         NativeLibrary.reloadKeys() | ||||
|  | ||||
|         // Retrieve list of cached games | ||||
|         val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext) | ||||
|             .getStringSet(GameHelper.KEY_GAMES, emptySet()) | ||||
|         if (storedGames!!.isNotEmpty()) { | ||||
|             val deserializedGames = mutableSetOf<Game>() | ||||
|             storedGames.forEach { | ||||
|                 val game: Game = Json.decodeFromString(it) | ||||
|                 val gameExists = | ||||
|                     DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path)) | ||||
|                         ?.exists() | ||||
|                 if (gameExists == true) { | ||||
|                     deserializedGames.add(game) | ||||
|                 } | ||||
|             } | ||||
|             setGames(deserializedGames.toList()) | ||||
|         } | ||||
|         reloadGames(false) | ||||
|     } | ||||
|  | ||||
|     fun setGames(games: List<Game>) { | ||||
|         val sortedList = games.sortedWith( | ||||
|             compareBy( | ||||
|                 { it.title.lowercase(Locale.getDefault()) }, | ||||
|                 { it.path } | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|         _games.postValue(sortedList) | ||||
|     } | ||||
|  | ||||
|     fun setSearchedGames(games: List<Game>) { | ||||
|         _searchedGames.postValue(games) | ||||
|     } | ||||
|  | ||||
|     fun setShouldSwapData(shouldSwap: Boolean) { | ||||
|         _shouldSwapData.postValue(shouldSwap) | ||||
|     } | ||||
|  | ||||
|     fun setShouldScrollToTop(shouldScroll: Boolean) { | ||||
|         _shouldScrollToTop.postValue(shouldScroll) | ||||
|     } | ||||
|  | ||||
|     fun setSearchFocused(searchFocused: Boolean) { | ||||
|         _searchFocused.postValue(searchFocused) | ||||
|     } | ||||
|  | ||||
|     fun reloadGames(directoryChanged: Boolean) { | ||||
|         if (isReloading.value == true) | ||||
|             return | ||||
|         _isReloading.postValue(true) | ||||
|  | ||||
|         viewModelScope.launch { | ||||
|             withContext(Dispatchers.IO) { | ||||
|                 NativeLibrary.resetRomMetadata() | ||||
|                 setGames(GameHelper.getGames()) | ||||
|                 _isReloading.postValue(false) | ||||
|  | ||||
|                 if (directoryChanged) { | ||||
|                     setShouldSwapData(true) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| ) | ||||
| @@ -0,0 +1,36 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.model | ||||
|  | ||||
| import androidx.lifecycle.LiveData | ||||
| import androidx.lifecycle.MutableLiveData | ||||
| import androidx.lifecycle.ViewModel | ||||
|  | ||||
| class HomeViewModel : ViewModel() { | ||||
|     private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>() | ||||
|     val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible | ||||
|  | ||||
|     private val _statusBarShadeVisible = MutableLiveData(true) | ||||
|     val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible | ||||
|  | ||||
|     var navigatedToSetup = false | ||||
|  | ||||
|     init { | ||||
|         _navigationVisible.value = Pair(false, false) | ||||
|     } | ||||
|  | ||||
|     fun setNavigationVisibility(visible: Boolean, animated: Boolean) { | ||||
|         if (_navigationVisible.value?.first == visible) { | ||||
|             return | ||||
|         } | ||||
|         _navigationVisible.value = Pair(visible, animated) | ||||
|     } | ||||
|  | ||||
|     fun setStatusBarShadeVisibility(visible: Boolean) { | ||||
|         if (_statusBarShadeVisible.value == visible) { | ||||
|             return | ||||
|         } | ||||
|         _statusBarShadeVisible.value = visible | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 } | ||||
| ) | ||||
| @@ -0,0 +1,47 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.model | ||||
|  | ||||
| import androidx.lifecycle.LiveData | ||||
| import androidx.lifecycle.MutableLiveData | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
|  | ||||
| class TaskViewModel : ViewModel() { | ||||
|     private val _result = MutableLiveData<Any>() | ||||
|     val result: LiveData<Any> = _result | ||||
|  | ||||
|     private val _isComplete = MutableLiveData<Boolean>() | ||||
|     val isComplete: LiveData<Boolean> = _isComplete | ||||
|  | ||||
|     private val _isRunning = MutableLiveData<Boolean>() | ||||
|     val isRunning: LiveData<Boolean> = _isRunning | ||||
|  | ||||
|     lateinit var task: () -> Any | ||||
|  | ||||
|     init { | ||||
|         clear() | ||||
|     } | ||||
|  | ||||
|     fun clear() { | ||||
|         _result.value = Any() | ||||
|         _isComplete.value = false | ||||
|         _isRunning.value = false | ||||
|     } | ||||
|  | ||||
|     fun runTask() { | ||||
|         if (_isRunning.value == true) { | ||||
|             return | ||||
|         } | ||||
|         _isRunning.value = true | ||||
|  | ||||
|         viewModelScope.launch(Dispatchers.IO) { | ||||
|             val res = task() | ||||
|             _result.postValue(res) | ||||
|             _isComplete.postValue(true) | ||||
|         } | ||||
|     } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|         } | ||||
| } | ||||
| @@ -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() | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| // SPDX-FileCopyrightText: 2023 yuzu Emulator Project | ||||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||||
|  | ||||
| package org.yuzu.yuzu_emu.utils | ||||
|  | ||||
| class BiMap<K, V> { | ||||
|     private val forward: MutableMap<K, V> = HashMap() | ||||
|     private val backward: MutableMap<V, K> = HashMap() | ||||
|  | ||||
|     @Synchronized | ||||
|     fun add(key: K, value: V) { | ||||
|         forward[key] = value | ||||
|         backward[value] = key | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     fun getForward(key: K): V? { | ||||
|         return forward[key] | ||||
|     } | ||||
|  | ||||
|     @Synchronized | ||||
|     fun getBackward(key: V): K? { | ||||
|         return backward[key] | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user