early-access version 3624
This commit is contained in:
parent
af7b0c7b4f
commit
12efe5764a
317 changed files with 19676 additions and 182 deletions
|
@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul
|
||||||
include(DownloadExternals)
|
include(DownloadExternals)
|
||||||
include(CMakeDependentOption)
|
include(CMakeDependentOption)
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
include(FetchContent)
|
||||||
|
|
||||||
# Set bundled sdl2/qt as dependent options.
|
# Set bundled sdl2/qt as dependent options.
|
||||||
# OFF by default, but if ENABLE_SDL2 and MSVC are true then ON
|
# 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
|
# 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)
|
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)
|
option(ENABLE_LIBUSB "Enable the use of LibUSB" "NOT ${ANDROID}")
|
||||||
|
|
||||||
option(ENABLE_OPENGL "Enable OpenGL" ON)
|
option(ENABLE_OPENGL "Enable OpenGL" ON)
|
||||||
mark_as_advanced(FORCE ENABLE_OPENGL)
|
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_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON)
|
||||||
|
|
||||||
option(YUZU_ROOM "Compile LDN room server" ON)
|
option(YUZU_ROOM "Compile LDN room server" "NOT ${ANDROID}")
|
||||||
|
|
||||||
CMAKE_DEPENDENT_OPTION(YUZU_CRASH_DUMPS "Compile Windows crash dump (Minidump) support" OFF "WIN32" 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)
|
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 (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)
|
if (YUZU_TESTS)
|
||||||
list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests")
|
list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests")
|
||||||
endif()
|
endif()
|
||||||
|
@ -457,7 +518,7 @@ set(FFmpeg_COMPONENTS
|
||||||
avutil
|
avutil
|
||||||
swscale)
|
swscale)
|
||||||
|
|
||||||
if (UNIX AND NOT APPLE)
|
if (UNIX AND NOT APPLE AND NOT ANDROID)
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
pkg_check_modules(LIBVA libva)
|
pkg_check_modules(LIBVA libva)
|
||||||
endif()
|
endif()
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
# prefix_var: name of a variable which will be set with the path to the extracted contents
|
# 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)
|
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_repo "no_platform")
|
||||||
set(package_extension "no_platform")
|
set(package_extension "no_platform")
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
|
@ -15,10 +16,14 @@ if (WIN32)
|
||||||
elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux")
|
||||||
set(package_repo "ext-linux-bin/raw/main/")
|
set(package_repo "ext-linux-bin/raw/main/")
|
||||||
set(package_extension ".tar.xz")
|
set(package_extension ".tar.xz")
|
||||||
|
elseif (ANDROID)
|
||||||
|
set(package_base_url "https://gitlab.com/tertius42/")
|
||||||
|
set(package_repo "ext-android-bin/-/raw/main/")
|
||||||
|
set(package_extension ".tar.xz")
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "No package available for this platform")
|
message(FATAL_ERROR "No package available for this platform")
|
||||||
endif()
|
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}")
|
set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}")
|
||||||
if (NOT EXISTS "${prefix}")
|
if (NOT EXISTS "${prefix}")
|
||||||
|
|
373
LICENSES/MPL-2.0.txt
Executable file
373
LICENSES/MPL-2.0.txt
Executable 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.
|
|
@ -1,7 +1,7 @@
|
||||||
yuzu emulator early access
|
yuzu emulator early access
|
||||||
=============
|
=============
|
||||||
|
|
||||||
This is the source code for early-access 3623.
|
This is the source code for early-access 3624.
|
||||||
|
|
||||||
## Legal Notice
|
## Legal Notice
|
||||||
|
|
||||||
|
|
6
externals/CMakeLists.txt
vendored
6
externals/CMakeLists.txt
vendored
|
@ -147,3 +147,9 @@ endif()
|
||||||
|
|
||||||
add_library(stb stb/stb_dxt.cpp)
|
add_library(stb stb/stb_dxt.cpp)
|
||||||
target_include_directories(stb PUBLIC ./stb)
|
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-FileCopyrightText: 2021 yuzu Emulator Project
|
||||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
if (NOT WIN32)
|
if (NOT WIN32 AND NOT ANDROID)
|
||||||
# Build FFmpeg from externals
|
# Build FFmpeg from externals
|
||||||
message(STATUS "Using FFmpeg from externals")
|
message(STATUS "Using FFmpeg from externals")
|
||||||
|
|
||||||
|
@ -44,10 +44,12 @@ if (NOT WIN32)
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
find_package(PkgConfig REQUIRED)
|
find_package(PkgConfig REQUIRED)
|
||||||
|
if (NOT ANDROID)
|
||||||
pkg_check_modules(LIBVA libva)
|
pkg_check_modules(LIBVA libva)
|
||||||
pkg_check_modules(CUDA cuda)
|
pkg_check_modules(CUDA cuda)
|
||||||
pkg_check_modules(FFNVCODEC ffnvcodec)
|
pkg_check_modules(FFNVCODEC ffnvcodec)
|
||||||
pkg_check_modules(VDPAU vdpau)
|
pkg_check_modules(VDPAU vdpau)
|
||||||
|
endif()
|
||||||
|
|
||||||
set(FFmpeg_HWACCEL_LIBRARIES)
|
set(FFmpeg_HWACCEL_LIBRARIES)
|
||||||
set(FFmpeg_HWACCEL_FLAGS)
|
set(FFmpeg_HWACCEL_FLAGS)
|
||||||
|
@ -121,6 +123,26 @@ if (NOT WIN32)
|
||||||
list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau)
|
list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau)
|
||||||
endif()
|
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
|
# `configure` parameters builds only exactly what yuzu needs from FFmpeg
|
||||||
# `--disable-vdpau` is needed to avoid linking issues
|
# `--disable-vdpau` is needed to avoid linking issues
|
||||||
set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER})
|
set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER})
|
||||||
|
@ -129,7 +151,7 @@ if (NOT WIN32)
|
||||||
OUTPUT
|
OUTPUT
|
||||||
${FFmpeg_MAKEFILE}
|
${FFmpeg_MAKEFILE}
|
||||||
COMMAND
|
COMMAND
|
||||||
/bin/bash ${FFmpeg_PREFIX}/configure
|
${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure
|
||||||
--disable-avdevice
|
--disable-avdevice
|
||||||
--disable-avformat
|
--disable-avformat
|
||||||
--disable-doc
|
--disable-doc
|
||||||
|
@ -146,12 +168,14 @@ if (NOT WIN32)
|
||||||
--cc="${FFmpeg_CC}"
|
--cc="${FFmpeg_CC}"
|
||||||
--cxx="${FFmpeg_CXX}"
|
--cxx="${FFmpeg_CXX}"
|
||||||
${FFmpeg_HWACCEL_FLAGS}
|
${FFmpeg_HWACCEL_FLAGS}
|
||||||
|
${FFmpeg_CROSS_COMPILE_FLAGS}
|
||||||
WORKING_DIRECTORY
|
WORKING_DIRECTORY
|
||||||
${FFmpeg_BUILD_DIR}
|
${FFmpeg_BUILD_DIR}
|
||||||
)
|
)
|
||||||
unset(FFmpeg_CC)
|
unset(FFmpeg_CC)
|
||||||
unset(FFmpeg_CXX)
|
unset(FFmpeg_CXX)
|
||||||
unset(FFmpeg_HWACCEL_FLAGS)
|
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
|
# 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.
|
# with context of the jobserver. Also helps ninja users.
|
||||||
|
@ -197,7 +221,38 @@ if (NOT WIN32)
|
||||||
else()
|
else()
|
||||||
message(FATAL_ERROR "FFmpeg not found")
|
message(FATAL_ERROR "FFmpeg not found")
|
||||||
endif()
|
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
|
# Use yuzu FFmpeg binaries
|
||||||
set(FFmpeg_EXT_NAME "ffmpeg-5.1.3")
|
set(FFmpeg_EXT_NAME "ffmpeg-5.1.3")
|
||||||
set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}")
|
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_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_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE)
|
||||||
set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" 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
|
set(FFmpeg_LIBRARIES
|
||||||
${FFmpeg_LIBRARY_DIR}/swscale.lib
|
${FFmpeg_LIBRARY_DIR}/swscale.lib
|
||||||
${FFmpeg_LIBRARY_DIR}/avcodec.lib
|
${FFmpeg_LIBRARY_DIR}/avcodec.lib
|
||||||
|
|
|
@ -195,3 +195,8 @@ endif()
|
||||||
if (ENABLE_WEB_SERVICE)
|
if (ENABLE_WEB_SERVICE)
|
||||||
add_subdirectory(web_service)
|
add_subdirectory(web_service)
|
||||||
endif()
|
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
Executable file
65
src/android/.gitignore
vendored
Executable 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
|
245
src/android/app/build.gradle.kts
Executable file
245
src/android/app/build.gradle.kts
Executable file
|
@ -0,0 +1,245 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
// 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 {
|
||||||
|
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
Executable file
24
src/android/app/proguard-rules.pro
vendored
Executable 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
Executable file
22
src/android/app/src/ea/res/drawable/ic_yuzu.xml
Executable 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
Executable file
12
src/android/app/src/ea/res/drawable/ic_yuzu_full.xml
Executable 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
Executable file
24
src/android/app/src/ea/res/drawable/ic_yuzu_title.xml
Executable 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
Executable file
91
src/android/app/src/main/AndroidManifest.xml
Executable 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
Executable file
508
src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt
Executable 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
|
||||||
|
}
|
||||||
|
}
|
61
src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
Executable file
61
src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
345
src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
Executable file
345
src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt
Executable file
|
@ -0,0 +1,345 @@
|
||||||
|
// 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.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.slider.Slider.OnChangeListener
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
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.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 menuVisible = 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
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopForegroundService(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.setTheme(this)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 restoreState(savedInstanceState: Bundle) {
|
||||||
|
game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableFullscreenImmersive() {
|
||||||
|
window.attributes.layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||||
|
|
||||||
|
// It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar.
|
||||||
|
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
|
||||||
|
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN or
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun editControlsPlacement() {
|
||||||
|
if (emulationFragment!!.isConfiguringControls) {
|
||||||
|
emulationFragment!!.stopConfiguringControls()
|
||||||
|
} else {
|
||||||
|
emulationFragment!!.startConfiguringControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adjustScale() {
|
||||||
|
val sliderBinding = DialogSliderBinding.inflate(layoutInflater)
|
||||||
|
sliderBinding.slider.valueTo = 150F
|
||||||
|
sliderBinding.slider.value =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat()
|
||||||
|
sliderBinding.slider.addOnChangeListener(OnChangeListener { _, value, _ ->
|
||||||
|
sliderBinding.textValue.text = value.toString()
|
||||||
|
setControlScale(value.toInt())
|
||||||
|
})
|
||||||
|
sliderBinding.textValue.text = sliderBinding.slider.value.toString()
|
||||||
|
sliderBinding.textUnits.text = "%"
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.emulation_control_scale)
|
||||||
|
.setView(sliderBinding.root)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
|
||||||
|
setControlScale(sliderBinding.slider.value.toInt())
|
||||||
|
}
|
||||||
|
.setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int ->
|
||||||
|
setControlScale(50)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlScale(scale: Int) {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
|
||||||
|
.putInt(Settings.PREF_CONTROL_SCALE, scale)
|
||||||
|
.apply()
|
||||||
|
emulationFragment!!.refreshInputOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetOverlay() {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(getString(R.string.emulation_touch_overlay_reset))
|
||||||
|
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationFragment!!.resetInputOverlay() }
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_SELECTED_GAME = "SelectedGame"
|
||||||
|
|
||||||
|
fun launch(activity: AppCompatActivity, game: Game) {
|
||||||
|
val launcher = Intent(activity, EmulationActivity::class.java)
|
||||||
|
launcher.putExtra(EXTRA_SELECTED_GAME, game)
|
||||||
|
activity.startActivity(launcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopForegroundService(activity: Activity) {
|
||||||
|
val startIntent = Intent(activity, ForegroundService::class.java)
|
||||||
|
startIntent.action = ForegroundService.ACTION_STOP
|
||||||
|
activity.startForegroundService(startIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
|
||||||
|
if (view == null) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val viewBounds = Rect()
|
||||||
|
view.getGlobalVisibleRect(viewBounds)
|
||||||
|
return !viewBounds.contains(x.roundToInt(), y.roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
134
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
Executable file
134
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/GameAdapter.kt
Executable file
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
70
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
Executable file
70
src/android/app/src/main/java/org/yuzu/yuzu_emu/adapters/SetupAdapter.kt
Executable file
|
@ -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(android.R.string.ok) }
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
300
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
Executable file
300
src/android/app/src/main/java/org/yuzu/yuzu_emu/features/DocumentProvider.kt
Executable file
|
@ -0,0 +1,300 @@
|
||||||
|
// 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.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 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,156 @@
|
||||||
|
// 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) {
|
||||||
|
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 PREF_OVERLAY_INIT = "OverlayInit"
|
||||||
|
const val PREF_CONTROL_SCALE = "controlScale"
|
||||||
|
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,12 @@
|
||||||
|
// 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 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,15 @@
|
||||||
|
// 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(
|
||||||
|
setting: AbstractSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val menuKey: String
|
||||||
|
) : SettingsItem(setting, 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,249 @@
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Update framebuffer layout when closing the settings
|
||||||
|
NativeLibrary.notifyOrientationChange(
|
||||||
|
EmulationMenuSettings.landscapeScreenLayout,
|
||||||
|
windowManager.defaultDisplay.rotation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,453 @@
|
||||||
|
// 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)
|
||||||
|
else -> {
|
||||||
|
fragmentView.showToastMessage("Unimplemented menu", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsList = sl
|
||||||
|
fragmentView.showSettingsList(settingsList!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addConfigSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_advanced_settings))
|
||||||
|
sl.apply {
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
null,
|
||||||
|
R.string.preferences_general,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_GENERAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
null,
|
||||||
|
R.string.preferences_system,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_SYSTEM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
null,
|
||||||
|
R.string.preferences_graphics,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_RENDERER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
null,
|
||||||
|
R.string.preferences_audio,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_AUDIO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
RunnableSetting(
|
||||||
|
R.string.reset_to_default,
|
||||||
|
0
|
||||||
|
) {
|
||||||
|
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_BACKEND,
|
||||||
|
R.string.renderer_api,
|
||||||
|
0,
|
||||||
|
R.array.rendererApiNames,
|
||||||
|
R.array.rendererApiValues,
|
||||||
|
IntSetting.RENDERER_BACKEND.key,
|
||||||
|
IntSetting.RENDERER_BACKEND.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_DEBUG,
|
||||||
|
R.string.renderer_debug,
|
||||||
|
R.string.renderer_debug_description,
|
||||||
|
IntSetting.RENDERER_DEBUG.key,
|
||||||
|
IntSetting.RENDERER_DEBUG.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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,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.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) {
|
||||||
|
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,238 @@
|
||||||
|
// 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?
|
||||||
|
): 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
121
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
Executable file
121
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/AboutFragment.kt
Executable file
|
@ -0,0 +1,121 @@
|
||||||
|
// 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.content.ContextCompat
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
parentFragmentManager.primaryNavigationFragment?.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.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
|
||||||
|
}
|
||||||
|
}
|
502
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
Executable file
502
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt
Executable file
|
@ -0,0 +1,502 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
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.fragment.app.Fragment
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
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.FragmentEmulationBinding
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshInputOverlay() {
|
||||||
|
binding.surfaceInputOverlay.refreshControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInputOverlay() {
|
||||||
|
// Reset button scale
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_CONTROL_SCALE, 50)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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_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()
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startConfiguringControls() {
|
||||||
|
binding.doneControlConfig.visibility = View.VISIBLE
|
||||||
|
binding.surfaceInputOverlay.setIsInEditMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopConfiguringControls() {
|
||||||
|
binding.doneControlConfig.visibility = View.GONE
|
||||||
|
binding.surfaceInputOverlay.setIsInEditMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isConfiguringControls: Boolean
|
||||||
|
get() = binding.surfaceInputOverlay.isInEditMode
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
// Release the surface before pausing, since emulation has to be running for that.
|
||||||
|
NativeLibrary.surfaceDestroyed()
|
||||||
|
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 -> {
|
||||||
|
NativeLibrary.surfaceDestroyed()
|
||||||
|
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,291 @@
|
||||||
|
// 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.content.ContextCompat
|
||||||
|
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 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.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.install_prod_keys,
|
||||||
|
R.string.install_prod_keys_description,
|
||||||
|
R.drawable.ic_unlock
|
||||||
|
) { mainActivity.getProdKey.launch(arrayOf("*/*")) },
|
||||||
|
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 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,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"
|
||||||
|
}
|
||||||
|
}
|
236
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
Executable file
236
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SearchFragment.kt
Executable file
|
@ -0,0 +1,236 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import info.debatty.java.stringsimilarity.Jaccard
|
||||||
|
import info.debatty.java.stringsimilarity.JaroWinkler
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding
|
||||||
|
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class SearchFragment : Fragment() {
|
||||||
|
private var _binding: FragmentSearchBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SEARCH_TEXT = "SearchText"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSearchBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = true, animated = false)
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.gridGamesSearch.apply {
|
||||||
|
layoutManager = AutofitGridLayoutManager(
|
||||||
|
requireContext(),
|
||||||
|
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
||||||
|
)
|
||||||
|
adapter = GameAdapter(requireActivity() as AppCompatActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
|
||||||
|
|
||||||
|
binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
|
||||||
|
if (text.toString().isNotEmpty()) {
|
||||||
|
binding.clearButton.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.clearButton.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
filterAndSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
gamesViewModel.apply {
|
||||||
|
searchFocused.observe(viewLifecycleOwner) { searchFocused ->
|
||||||
|
if (searchFocused) {
|
||||||
|
focusSearch()
|
||||||
|
gamesViewModel.setSearchFocused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games.observe(viewLifecycleOwner) { filterAndSearch() }
|
||||||
|
searchedGames.observe(viewLifecycleOwner) {
|
||||||
|
(binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
binding.noResultsView.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.noResultsView.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.clearButton.setOnClickListener { binding.searchText.setText("") }
|
||||||
|
|
||||||
|
binding.searchBackground.setOnClickListener { focusSearch() }
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
filterAndSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ScoredGame(val score: Double, val item: Game)
|
||||||
|
|
||||||
|
private fun filterAndSearch() {
|
||||||
|
val baseList = gamesViewModel.games.value!!
|
||||||
|
val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) {
|
||||||
|
R.id.chip_recently_played -> {
|
||||||
|
baseList.filter {
|
||||||
|
val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
|
||||||
|
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_recently_added -> {
|
||||||
|
baseList.filter {
|
||||||
|
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
|
||||||
|
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_homebrew -> {
|
||||||
|
baseList.filter {
|
||||||
|
Log.error("Guh - ${it.path}")
|
||||||
|
FileUtil.hasExtension(it.path, "nro")
|
||||||
|
|| FileUtil.hasExtension(it.path, "nso")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_retail -> baseList.filter {
|
||||||
|
FileUtil.hasExtension(it.path, "xci")
|
||||||
|
|| FileUtil.hasExtension(it.path, "nsp")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> baseList
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.searchText.text.toString().isEmpty()
|
||||||
|
&& binding.chipGroup.checkedChipId != View.NO_ID
|
||||||
|
) {
|
||||||
|
gamesViewModel.setSearchedGames(filteredList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
|
||||||
|
val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
|
||||||
|
val sortedList: List<Game> = filteredList.mapNotNull { game ->
|
||||||
|
val title = game.title.lowercase(Locale.getDefault())
|
||||||
|
val score = searchAlgorithm.similarity(searchTerm, title)
|
||||||
|
if (score > 0.03) {
|
||||||
|
ScoredGame(score, game)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedByDescending { it.score }.map { it.item }
|
||||||
|
gamesViewModel.setSearchedGames(sortedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
if (_binding != null) {
|
||||||
|
outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun focusSearch() {
|
||||||
|
if (_binding != null) {
|
||||||
|
binding.searchText.requestFocus()
|
||||||
|
val imm =
|
||||||
|
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||||
|
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
|
||||||
|
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||||
|
val spacingNavigationRail =
|
||||||
|
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||||
|
val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
|
||||||
|
|
||||||
|
binding.constraintSearch.updatePadding(
|
||||||
|
left = barInsets.left + cutoutInsets.left,
|
||||||
|
top = barInsets.top,
|
||||||
|
right = barInsets.right + cutoutInsets.right
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.gridGamesSearch.updatePadding(
|
||||||
|
top = extraListSpacing,
|
||||||
|
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
|
||||||
|
)
|
||||||
|
binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
|
||||||
|
|
||||||
|
val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
|
||||||
|
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
binding.frameSearch.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.noResultsView.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.chipGroup.updatePadding(
|
||||||
|
left = chipSpacing + spacingNavigationRail,
|
||||||
|
right = chipSpacing
|
||||||
|
)
|
||||||
|
mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
|
||||||
|
mlpDivider.rightMargin = chipSpacing
|
||||||
|
} else {
|
||||||
|
binding.frameSearch.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.noResultsView.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.chipGroup.updatePadding(
|
||||||
|
left = chipSpacing,
|
||||||
|
right = chipSpacing + spacingNavigationRail
|
||||||
|
)
|
||||||
|
mlpDivider.leftMargin = chipSpacing
|
||||||
|
mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
|
||||||
|
}
|
||||||
|
binding.divider.layoutParams = mlpDivider
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
329
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
Executable file
329
src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/SetupFragment.kt
Executable file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
41
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
Executable file
41
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/Game.kt
Executable file
|
@ -0,0 +1,41 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.HashSet
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@Serializable
|
||||||
|
class Game(
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val regions: String,
|
||||||
|
val path: String,
|
||||||
|
val gameId: String,
|
||||||
|
val company: String
|
||||||
|
) : Parcelable {
|
||||||
|
val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
|
||||||
|
val keyLastPlayedTime get() = "${gameId}_LastPlayed"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is Game)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return title == other.title
|
||||||
|
&& description == other.description
|
||||||
|
&& regions == other.regions
|
||||||
|
&& path == other.path
|
||||||
|
&& gameId == other.gameId
|
||||||
|
&& company == other.company
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val extensions: Set<String> = HashSet(
|
||||||
|
listOf(".xci", ".nsp", ".nca", ".nro")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
109
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
Executable file
109
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/GamesViewModel.kt
Executable file
|
@ -0,0 +1,109 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.utils.GameHelper
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class GamesViewModel : ViewModel() {
|
||||||
|
private val _games = MutableLiveData<List<Game>>(emptyList())
|
||||||
|
val games: LiveData<List<Game>> get() = _games
|
||||||
|
|
||||||
|
private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
|
||||||
|
val searchedGames: LiveData<List<Game>> get() = _searchedGames
|
||||||
|
|
||||||
|
private val _isReloading = MutableLiveData(false)
|
||||||
|
val isReloading: LiveData<Boolean> get() = _isReloading
|
||||||
|
|
||||||
|
private val _shouldSwapData = MutableLiveData(false)
|
||||||
|
val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
|
||||||
|
|
||||||
|
private val _shouldScrollToTop = MutableLiveData(false)
|
||||||
|
val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop
|
||||||
|
|
||||||
|
private val _searchFocused = MutableLiveData(false)
|
||||||
|
val searchFocused: LiveData<Boolean> get() = _searchFocused
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||||
|
NativeLibrary.reloadKeys()
|
||||||
|
|
||||||
|
// Retrieve list of cached games
|
||||||
|
val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
.getStringSet(GameHelper.KEY_GAMES, emptySet())
|
||||||
|
if (storedGames!!.isNotEmpty()) {
|
||||||
|
val deserializedGames = mutableSetOf<Game>()
|
||||||
|
storedGames.forEach {
|
||||||
|
val game: Game = Json.decodeFromString(it)
|
||||||
|
val gameExists =
|
||||||
|
DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path))
|
||||||
|
?.exists()
|
||||||
|
if (gameExists == true) {
|
||||||
|
deserializedGames.add(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGames(deserializedGames.toList())
|
||||||
|
}
|
||||||
|
reloadGames(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGames(games: List<Game>) {
|
||||||
|
val sortedList = games.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ it.title.lowercase(Locale.getDefault()) },
|
||||||
|
{ it.path }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_games.postValue(sortedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchedGames(games: List<Game>) {
|
||||||
|
_searchedGames.postValue(games)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShouldSwapData(shouldSwap: Boolean) {
|
||||||
|
_shouldSwapData.postValue(shouldSwap)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShouldScrollToTop(shouldScroll: Boolean) {
|
||||||
|
_shouldScrollToTop.postValue(shouldScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchFocused(searchFocused: Boolean) {
|
||||||
|
_searchFocused.postValue(searchFocused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadGames(directoryChanged: Boolean) {
|
||||||
|
if (isReloading.value == true)
|
||||||
|
return
|
||||||
|
_isReloading.postValue(true)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeLibrary.resetRomMetadata()
|
||||||
|
setGames(GameHelper.getGames())
|
||||||
|
_isReloading.postValue(false)
|
||||||
|
|
||||||
|
if (directoryChanged) {
|
||||||
|
setShouldSwapData(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
Executable file
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeSetting.kt
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
data class HomeSetting(
|
||||||
|
val titleId: Int,
|
||||||
|
val descriptionId: Int,
|
||||||
|
val iconId: Int,
|
||||||
|
val onClick: () -> Unit
|
||||||
|
)
|
36
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
Executable file
36
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/HomeViewModel.kt
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
Executable file
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/MinimalDocumentFile.kt
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
|
||||||
|
class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) {
|
||||||
|
val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
}
|
19
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
Executable file
19
src/android/app/src/main/java/org/yuzu/yuzu_emu/model/SetupPage.kt
Executable file
|
@ -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 }
|
||||||
|
)
|
1002
src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
Executable file
1002
src/android/app/src/main/java/org/yuzu/yuzu_emu/overlay/InputOverlay.kt
Executable file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,142 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
val status: Int
|
||||||
|
get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
val bounds: Rect
|
||||||
|
get() = defaultStateBitmap.bounds
|
||||||
|
}
|
|
@ -0,0 +1,267 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
val bounds: Rect
|
||||||
|
get() = defaultStateBitmap.bounds
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VIRT_AXIS_DEADZONE = 0.5f
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,264 @@
|
||||||
|
// 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 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 = 255
|
||||||
|
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 = 255
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
165
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
Executable file
165
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/GamesFragment.kt
Executable file
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
418
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
Executable file
418
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/MainActivity.kt
Executable file
|
@ -0,0 +1,418 @@
|
||||||
|
// 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.ui.SettingsActivity
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.utils.*
|
||||||
|
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()
|
||||||
|
|
||||||
|
override var themeId: Int = 0
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
val splashScreen = installSplashScreen()
|
||||||
|
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
|
||||||
|
|
||||||
|
ThemeHelper.setTheme(this)
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
val takeFlags =
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
takeFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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")) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.invalid_keys_file,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val takeFlags =
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
takeFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.install_keys_failure,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val getAmiiboKey =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
if (!FileUtil.hasExtension(result.toString(), "bin")) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.invalid_keys_file,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val takeFlags =
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
takeFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.install_amiibo_keys_failure,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val getDriver =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
val takeFlags =
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
takeFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
|
||||||
|
progressBinding.progressBar.isIndeterminate = true
|
||||||
|
val installationDialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.installing_driver)
|
||||||
|
.setView(progressBinding.root)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Ignore file exceptions when a user selects an invalid zip
|
||||||
|
try {
|
||||||
|
GpuDriverHelper.installCustomDriver(applicationContext, result)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
installationDialog.dismiss()
|
||||||
|
|
||||||
|
val driverName = GpuDriverHelper.customDriverName
|
||||||
|
if (driverName != null) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
getString(
|
||||||
|
R.string.select_gpu_driver_install_success,
|
||||||
|
driverName
|
||||||
|
),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.select_gpu_driver_error,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
Executable file
11
src/android/app/src/main/java/org/yuzu/yuzu_emu/ui/main/ThemeProvider.kt
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.ui.main
|
||||||
|
|
||||||
|
interface ThemeProvider {
|
||||||
|
/**
|
||||||
|
* Provides theme ID by overriding an activity's 'setTheme' method and returning that result
|
||||||
|
*/
|
||||||
|
var themeId: Int
|
||||||
|
}
|
25
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
Executable file
25
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/BiMap.kt
Executable file
|
@ -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]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some controllers have incorrect mappings. This class has special-case fixes for them.
|
||||||
|
*/
|
||||||
|
class ControllerMappingHelper {
|
||||||
|
/**
|
||||||
|
* Some controllers report extra button presses that can be ignored.
|
||||||
|
*/
|
||||||
|
fun shouldKeyBeIgnored(inputDevice: InputDevice, keyCode: Int): Boolean {
|
||||||
|
return if (isDualShock4(inputDevice)) {
|
||||||
|
// The two analog triggers generate analog motion events as well as a keycode.
|
||||||
|
// We always prefer to use the analog values, so throw away the button press
|
||||||
|
keyCode == KeyEvent.KEYCODE_BUTTON_L2 || keyCode == KeyEvent.KEYCODE_BUTTON_R2
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale an axis to be zero-centered with a proper range.
|
||||||
|
*/
|
||||||
|
fun scaleAxis(inputDevice: InputDevice, axis: Int, value: Float): Float {
|
||||||
|
if (isDualShock4(inputDevice)) {
|
||||||
|
// Android doesn't have correct mappings for this controller's triggers. It reports them
|
||||||
|
// as RX & RY, centered at -1.0, and with a range of [-1.0, 1.0]
|
||||||
|
// Scale them to properly zero-centered with a range of [0.0, 1.0].
|
||||||
|
if (axis == MotionEvent.AXIS_RX || axis == MotionEvent.AXIS_RY) {
|
||||||
|
return (value + 1) / 2.0f
|
||||||
|
}
|
||||||
|
} else if (isXboxOneWireless(inputDevice)) {
|
||||||
|
// Same as the DualShock 4, the mappings are missing.
|
||||||
|
if (axis == MotionEvent.AXIS_Z || axis == MotionEvent.AXIS_RZ) {
|
||||||
|
return (value + 1) / 2.0f
|
||||||
|
}
|
||||||
|
if (axis == MotionEvent.AXIS_GENERIC_1) {
|
||||||
|
// This axis is stuck at ~.5. Ignore it.
|
||||||
|
return 0.0f
|
||||||
|
}
|
||||||
|
} else if (isMogaPro2Hid(inputDevice)) {
|
||||||
|
// This controller has a broken axis that reports a constant value. Ignore it.
|
||||||
|
if (axis == MotionEvent.AXIS_GENERIC_1) {
|
||||||
|
return 0.0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sony DualShock 4 controller
|
||||||
|
private fun isDualShock4(inputDevice: InputDevice): Boolean {
|
||||||
|
return inputDevice.vendorId == 0x54c && inputDevice.productId == 0x9cc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microsoft Xbox One controller
|
||||||
|
private fun isXboxOneWireless(inputDevice: InputDevice): Boolean {
|
||||||
|
return inputDevice.vendorId == 0x45e && inputDevice.productId == 0x2e0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moga Pro 2 HID
|
||||||
|
private fun isMogaPro2Hid(inputDevice: InputDevice): Boolean {
|
||||||
|
return inputDevice.vendorId == 0x20d6 && inputDevice.productId == 0x6271
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
object DirectoryInitialization {
|
||||||
|
private var userPath: String? = null
|
||||||
|
|
||||||
|
var areDirectoriesReady: Boolean = false
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (!areDirectoriesReady) {
|
||||||
|
initializeInternalStorage(context)
|
||||||
|
NativeLibrary.initializeEmulation()
|
||||||
|
areDirectoriesReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val userDirectory: String?
|
||||||
|
get() {
|
||||||
|
check(areDirectoriesReady) { "Directory initialization is not ready!" }
|
||||||
|
return userPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeInternalStorage(context: Context) {
|
||||||
|
try {
|
||||||
|
userPath = context.getExternalFilesDir(null)!!.canonicalPath
|
||||||
|
NativeLibrary.setAppDirectory(userPath!!)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
112
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
Executable file
112
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/DocumentsTree.kt
Executable file
|
@ -0,0 +1,112 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
|
||||||
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class DocumentsTree {
|
||||||
|
private var root: DocumentsNode? = null
|
||||||
|
|
||||||
|
fun setRoot(rootUri: Uri?) {
|
||||||
|
root = null
|
||||||
|
root = DocumentsNode()
|
||||||
|
root!!.uri = rootUri
|
||||||
|
root!!.isDirectory = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openContentUri(filepath: String, openMode: String?): Int {
|
||||||
|
val node = resolvePath(filepath) ?: return -1
|
||||||
|
return FileUtil.openContentUri(YuzuApplication.appContext, node.uri.toString(), openMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFileSize(filepath: String): Long {
|
||||||
|
val node = resolvePath(filepath)
|
||||||
|
return if (node == null || node.isDirectory) {
|
||||||
|
0
|
||||||
|
} else FileUtil.getFileSize(YuzuApplication.appContext, node.uri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exists(filepath: String): Boolean {
|
||||||
|
return resolvePath(filepath) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvePath(filepath: String): DocumentsNode? {
|
||||||
|
val tokens = StringTokenizer(filepath, File.separator, false)
|
||||||
|
var iterator = root
|
||||||
|
while (tokens.hasMoreTokens()) {
|
||||||
|
val token = tokens.nextToken()
|
||||||
|
if (token.isEmpty()) continue
|
||||||
|
iterator = find(iterator, token)
|
||||||
|
if (iterator == null) return null
|
||||||
|
}
|
||||||
|
return iterator
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun find(parent: DocumentsNode?, filename: String): DocumentsNode? {
|
||||||
|
if (parent!!.isDirectory && !parent.loaded) {
|
||||||
|
structTree(parent)
|
||||||
|
}
|
||||||
|
return parent.children[filename]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct current level directory tree
|
||||||
|
* @param parent parent node of this level
|
||||||
|
*/
|
||||||
|
private fun structTree(parent: DocumentsNode) {
|
||||||
|
val documents = FileUtil.listFiles(YuzuApplication.appContext, parent.uri!!)
|
||||||
|
for (document in documents) {
|
||||||
|
val node = DocumentsNode(document)
|
||||||
|
node.parent = parent
|
||||||
|
parent.children[node.name] = node
|
||||||
|
}
|
||||||
|
parent.loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DocumentsNode {
|
||||||
|
var parent: DocumentsNode? = null
|
||||||
|
val children: MutableMap<String?, DocumentsNode> = HashMap()
|
||||||
|
var name: String? = null
|
||||||
|
var uri: Uri? = null
|
||||||
|
var loaded = false
|
||||||
|
var isDirectory = false
|
||||||
|
|
||||||
|
constructor()
|
||||||
|
constructor(document: MinimalDocumentFile) {
|
||||||
|
name = document.filename
|
||||||
|
uri = document.uri
|
||||||
|
isDirectory = document.isDirectory
|
||||||
|
loaded = !isDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(document: DocumentFile, isCreateDir: Boolean) {
|
||||||
|
name = document.name
|
||||||
|
uri = document.uri
|
||||||
|
isDirectory = isCreateDir
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rename(name: String) {
|
||||||
|
if (parent == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parent!!.children.remove(this.name)
|
||||||
|
this.name = name
|
||||||
|
parent!!.children[name] = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun isNativePath(path: String): Boolean {
|
||||||
|
return if (path.isNotEmpty()) {
|
||||||
|
path[0] == '/'
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
|
||||||
|
object EmulationMenuSettings {
|
||||||
|
private val preferences =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
|
||||||
|
// These must match what is defined in src/core/settings.h
|
||||||
|
const val LayoutOption_Default = 0
|
||||||
|
const val LayoutOption_SingleScreen = 1
|
||||||
|
const val LayoutOption_LargeScreen = 2
|
||||||
|
const val LayoutOption_SideScreen = 3
|
||||||
|
const val LayoutOption_MobilePortrait = 4
|
||||||
|
const val LayoutOption_MobileLandscape = 5
|
||||||
|
|
||||||
|
var joystickRelCenter: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, true)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
var dpadSlide: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, true)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_MENU_SETTINGS_DPAD_SLIDE, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
var hapticFeedback: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, false)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_MENU_SETTINGS_HAPTICS, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
var landscapeScreenLayout: Int
|
||||||
|
get() = preferences.getInt(
|
||||||
|
Settings.PREF_MENU_SETTINGS_LANDSCAPE,
|
||||||
|
LayoutOption_MobileLandscape
|
||||||
|
)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_MENU_SETTINGS_LANDSCAPE, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
var showFps: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, false)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_FPS, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
var showOverlay: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, true)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_MENU_SETTINGS_SHOW_OVERLAY, value)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
298
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
Executable file
298
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/FileUtil.kt
Executable file
|
@ -0,0 +1,298 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import org.yuzu.yuzu_emu.model.MinimalDocumentFile
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.net.URLDecoder
|
||||||
|
|
||||||
|
object FileUtil {
|
||||||
|
const val PATH_TREE = "tree"
|
||||||
|
const val DECODE_METHOD = "UTF-8"
|
||||||
|
const val APPLICATION_OCTET_STREAM = "application/octet-stream"
|
||||||
|
const val TEXT_PLAIN = "text/plain"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a file from directory with filename.
|
||||||
|
* @param context Application context
|
||||||
|
* @param directory parent path for file.
|
||||||
|
* @param filename file display name.
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
fun createFile(context: Context?, directory: String?, filename: String): DocumentFile? {
|
||||||
|
var decodedFilename = filename
|
||||||
|
try {
|
||||||
|
val directoryUri = Uri.parse(directory)
|
||||||
|
val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
|
||||||
|
decodedFilename = URLDecoder.decode(decodedFilename, DECODE_METHOD)
|
||||||
|
var mimeType = APPLICATION_OCTET_STREAM
|
||||||
|
if (decodedFilename.endsWith(".txt")) {
|
||||||
|
mimeType = TEXT_PLAIN
|
||||||
|
}
|
||||||
|
val exists = parent.findFile(decodedFilename)
|
||||||
|
return exists ?: parent.createFile(mimeType, decodedFilename)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a directory from directory with filename.
|
||||||
|
* @param context Application context
|
||||||
|
* @param directory parent path for directory.
|
||||||
|
* @param directoryName directory display name.
|
||||||
|
* @return boolean
|
||||||
|
*/
|
||||||
|
fun createDir(context: Context?, directory: String?, directoryName: String?): DocumentFile? {
|
||||||
|
var decodedDirectoryName = directoryName
|
||||||
|
try {
|
||||||
|
val directoryUri = Uri.parse(directory)
|
||||||
|
val parent = DocumentFile.fromTreeUri(context!!, directoryUri) ?: return null
|
||||||
|
decodedDirectoryName = URLDecoder.decode(decodedDirectoryName, DECODE_METHOD)
|
||||||
|
val isExist = parent.findFile(decodedDirectoryName)
|
||||||
|
return isExist ?: parent.createDirectory(decodedDirectoryName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot create file, error: " + e.message)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open content uri and return file descriptor to JNI.
|
||||||
|
* @param context Application context
|
||||||
|
* @param path Native content uri path
|
||||||
|
* @param openMode will be one of "r", "r", "rw", "wa", "rwa"
|
||||||
|
* @return file descriptor
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun openContentUri(context: Context, path: String, openMode: String?): Int {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(path)
|
||||||
|
val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, openMode!!)
|
||||||
|
if (parcelFileDescriptor == null) {
|
||||||
|
Log.error("[FileUtil]: Cannot get the file descriptor from uri: $path")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
val fileDescriptor = parcelFileDescriptor.detachFd()
|
||||||
|
parcelFileDescriptor.close()
|
||||||
|
return fileDescriptor
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot open content uri, error: " + e.message)
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference: https://stackoverflow.com/questions/42186820/documentfile-is-very-slow
|
||||||
|
* This function will be faster than DoucmentFile.listFiles
|
||||||
|
* @param context Application context
|
||||||
|
* @param uri Directory uri.
|
||||||
|
* @return CheapDocument lists.
|
||||||
|
*/
|
||||||
|
fun listFiles(context: Context, uri: Uri): Array<MinimalDocumentFile> {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val columns = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||||
|
)
|
||||||
|
var c: Cursor? = null
|
||||||
|
val results: MutableList<MinimalDocumentFile> = ArrayList()
|
||||||
|
try {
|
||||||
|
val docId: String = if (isRootTreeUri(uri)) {
|
||||||
|
DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
} else {
|
||||||
|
DocumentsContract.getDocumentId(uri)
|
||||||
|
}
|
||||||
|
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId)
|
||||||
|
c = resolver.query(childrenUri, columns, null, null, null)
|
||||||
|
while (c!!.moveToNext()) {
|
||||||
|
val documentId = c.getString(0)
|
||||||
|
val documentName = c.getString(1)
|
||||||
|
val documentMimeType = c.getString(2)
|
||||||
|
val documentUri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId)
|
||||||
|
val document = MinimalDocumentFile(documentName, documentMimeType, documentUri)
|
||||||
|
results.add(document)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot list file error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
closeQuietly(c)
|
||||||
|
}
|
||||||
|
return results.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether given path exists.
|
||||||
|
* @param path Native content uri path
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
fun exists(context: Context, path: String?): Boolean {
|
||||||
|
var c: Cursor? = null
|
||||||
|
try {
|
||||||
|
val mUri = Uri.parse(path)
|
||||||
|
val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
|
||||||
|
c = context.contentResolver.query(mUri, columns, null, null, null)
|
||||||
|
return c!!.count > 0
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.info("[FileUtil] Cannot find file from given path, error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
closeQuietly(c)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether given path is a directory
|
||||||
|
* @param path content uri path
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
fun isDirectory(context: Context, path: String): Boolean {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val columns = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE
|
||||||
|
)
|
||||||
|
var isDirectory = false
|
||||||
|
var c: Cursor? = null
|
||||||
|
try {
|
||||||
|
val mUri = Uri.parse(path)
|
||||||
|
c = resolver.query(mUri, columns, null, null, null)
|
||||||
|
c!!.moveToNext()
|
||||||
|
val mimeType = c.getString(0)
|
||||||
|
isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot list files, error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
closeQuietly(c)
|
||||||
|
}
|
||||||
|
return isDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file display name from given path
|
||||||
|
* @param path content uri path
|
||||||
|
* @return String display name
|
||||||
|
*/
|
||||||
|
fun getFilename(context: Context, path: String): String {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val columns = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||||
|
)
|
||||||
|
var filename = ""
|
||||||
|
var c: Cursor? = null
|
||||||
|
try {
|
||||||
|
val mUri = Uri.parse(path)
|
||||||
|
c = resolver.query(mUri, columns, null, null, null)
|
||||||
|
c!!.moveToNext()
|
||||||
|
filename = c.getString(0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
closeQuietly(c)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFilesName(context: Context, path: String): Array<String> {
|
||||||
|
val uri = Uri.parse(path)
|
||||||
|
val files: MutableList<String> = ArrayList()
|
||||||
|
for (file in listFiles(context, uri)) {
|
||||||
|
files.add(file.filename)
|
||||||
|
}
|
||||||
|
return files.toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file size from given path.
|
||||||
|
* @param path content uri path
|
||||||
|
* @return long file size
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getFileSize(context: Context, path: String): Long {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val columns = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE
|
||||||
|
)
|
||||||
|
var size: Long = 0
|
||||||
|
var c: Cursor? = null
|
||||||
|
try {
|
||||||
|
val mUri = Uri.parse(path)
|
||||||
|
c = resolver.query(mUri, columns, null, null, null)
|
||||||
|
c!!.moveToNext()
|
||||||
|
size = c.getLong(0)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot get file size, error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
closeQuietly(c)
|
||||||
|
}
|
||||||
|
return size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copyUriToInternalStorage(
|
||||||
|
context: Context,
|
||||||
|
sourceUri: Uri?,
|
||||||
|
destinationParentPath: String,
|
||||||
|
destinationFilename: String
|
||||||
|
): Boolean {
|
||||||
|
var input: InputStream? = null
|
||||||
|
var output: FileOutputStream? = null
|
||||||
|
try {
|
||||||
|
input = context.contentResolver.openInputStream(sourceUri!!)
|
||||||
|
output = FileOutputStream("$destinationParentPath/$destinationFilename")
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var len: Int
|
||||||
|
while (input!!.read(buffer).also { len = it } != -1) {
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
}
|
||||||
|
output.flush()
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.error("[FileUtil]: Cannot copy file, error: " + e.message)
|
||||||
|
} finally {
|
||||||
|
if (input != null) {
|
||||||
|
try {
|
||||||
|
input.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.error("[FileUtil]: Cannot close input file, error: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (output != null) {
|
||||||
|
try {
|
||||||
|
output.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.error("[FileUtil]: Cannot close output file, error: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isRootTreeUri(uri: Uri): Boolean {
|
||||||
|
val paths = uri.pathSegments
|
||||||
|
return paths.size == 2 && PATH_TREE == paths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeQuietly(closeable: AutoCloseable?) {
|
||||||
|
if (closeable != null) {
|
||||||
|
try {
|
||||||
|
closeable.close()
|
||||||
|
} catch (rethrown: RuntimeException) {
|
||||||
|
throw rethrown
|
||||||
|
} catch (ignored: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasExtension(path: String, extension: String): Boolean {
|
||||||
|
return path.substring(path.lastIndexOf(".") + 1).contains(extension)
|
||||||
|
}
|
||||||
|
}
|
67
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
Executable file
67
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/ForegroundService.kt
Executable file
|
@ -0,0 +1,67 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service that shows a permanent notification in the background to avoid the app getting
|
||||||
|
* cleared from memory by the system.
|
||||||
|
*/
|
||||||
|
class ForegroundService : Service() {
|
||||||
|
companion object {
|
||||||
|
const val EMULATION_RUNNING_NOTIFICATION = 0x1000
|
||||||
|
|
||||||
|
const val ACTION_STOP = "stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRunningNotification() {
|
||||||
|
// Intent is used to resume emulation if the notification is clicked
|
||||||
|
val contentIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, EmulationActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val builder =
|
||||||
|
NotificationCompat.Builder(this, getString(R.string.emulation_notification_channel_id))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_notification_logo)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(getString(R.string.emulation_notification_running))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setVibrate(null)
|
||||||
|
.setSound(null)
|
||||||
|
.setContentIntent(contentIntent)
|
||||||
|
startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
showRunningNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||||
|
if (intent.action == ACTION_STOP) {
|
||||||
|
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelfResult(startId)
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
|
||||||
|
}
|
||||||
|
}
|
98
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
Executable file
98
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GameHelper.kt
Executable file
|
@ -0,0 +1,98 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
object GameHelper {
|
||||||
|
const val KEY_GAME_PATH = "game_path"
|
||||||
|
const val KEY_GAMES = "Games"
|
||||||
|
|
||||||
|
private lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
fun getGames(): List<Game> {
|
||||||
|
val games = mutableListOf<Game>()
|
||||||
|
val context = YuzuApplication.appContext
|
||||||
|
val gamesDir =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_GAME_PATH, "")
|
||||||
|
val gamesUri = Uri.parse(gamesDir)
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||||
|
NativeLibrary.reloadKeys()
|
||||||
|
|
||||||
|
val children = FileUtil.listFiles(context, gamesUri)
|
||||||
|
for (file in children) {
|
||||||
|
if (!file.isDirectory) {
|
||||||
|
val filename = file.uri.toString()
|
||||||
|
val extensionStart = filename.lastIndexOf('.')
|
||||||
|
if (extensionStart > 0) {
|
||||||
|
val fileExtension = filename.substring(extensionStart)
|
||||||
|
|
||||||
|
// Check that the file has an extension we care about before trying to read out of it.
|
||||||
|
if (Game.extensions.contains(fileExtension.lowercase(Locale.getDefault()))) {
|
||||||
|
games.add(getGame(filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache list of games found on disk
|
||||||
|
val serializedGames = mutableSetOf<String>()
|
||||||
|
games.forEach {
|
||||||
|
serializedGames.add(Json.encodeToString(it))
|
||||||
|
}
|
||||||
|
preferences.edit()
|
||||||
|
.remove(KEY_GAMES)
|
||||||
|
.putStringSet(KEY_GAMES, serializedGames)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
return games.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGame(filePath: String): Game {
|
||||||
|
var name = NativeLibrary.getTitle(filePath)
|
||||||
|
|
||||||
|
// If the game's title field is empty, use the filename.
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
name = filePath.substring(filePath.lastIndexOf("/") + 1)
|
||||||
|
}
|
||||||
|
var gameId = NativeLibrary.getGameId(filePath)
|
||||||
|
|
||||||
|
// If the game's ID field is empty, use the filename without extension.
|
||||||
|
if (gameId.isEmpty()) {
|
||||||
|
gameId = filePath.substring(
|
||||||
|
filePath.lastIndexOf("/") + 1,
|
||||||
|
filePath.lastIndexOf(".")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val newGame = Game(
|
||||||
|
name,
|
||||||
|
NativeLibrary.getDescription(filePath).replace("\n", " "),
|
||||||
|
NativeLibrary.getRegions(filePath),
|
||||||
|
filePath,
|
||||||
|
gameId,
|
||||||
|
NativeLibrary.getCompany(filePath)
|
||||||
|
)
|
||||||
|
|
||||||
|
val addedTime = preferences.getLong(newGame.keyAddedToLibraryTime, 0L)
|
||||||
|
if (addedTime == 0L) {
|
||||||
|
preferences.edit()
|
||||||
|
.putLong(newGame.keyAddedToLibraryTime, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
return newGame
|
||||||
|
}
|
||||||
|
}
|
148
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
Executable file
148
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverHelper.kt
Executable file
|
@ -0,0 +1,148 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil.copyUriToInternalStorage
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
object GpuDriverHelper {
|
||||||
|
private const val META_JSON_FILENAME = "meta.json"
|
||||||
|
private const val DRIVER_INTERNAL_FILENAME = "gpu_driver.zip"
|
||||||
|
private var fileRedirectionPath: String? = null
|
||||||
|
private var driverInstallationPath: String? = null
|
||||||
|
private var hookLibPath: String? = null
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun unzip(zipFilePath: String, destDir: String) {
|
||||||
|
val dir = File(destDir)
|
||||||
|
|
||||||
|
// Create output directory if it doesn't exist
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
|
||||||
|
// Unpack the files.
|
||||||
|
val inputStream = FileInputStream(zipFilePath)
|
||||||
|
val zis = ZipInputStream(BufferedInputStream(inputStream))
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var ze = zis.nextEntry
|
||||||
|
while (ze != null) {
|
||||||
|
val newFile = File(destDir, ze.name)
|
||||||
|
val canonicalPath = newFile.canonicalPath
|
||||||
|
if (!canonicalPath.startsWith(destDir + ze.name)) {
|
||||||
|
throw SecurityException("Zip file attempted path traversal! " + ze.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
newFile.parentFile!!.mkdirs()
|
||||||
|
val fos = FileOutputStream(newFile)
|
||||||
|
var len: Int
|
||||||
|
while (zis.read(buffer).also { len = it } > 0) {
|
||||||
|
fos.write(buffer, 0, len)
|
||||||
|
}
|
||||||
|
fos.close()
|
||||||
|
zis.closeEntry()
|
||||||
|
ze = zis.nextEntry
|
||||||
|
}
|
||||||
|
zis.closeEntry()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeDriverParameters(context: Context) {
|
||||||
|
try {
|
||||||
|
// Initialize the file redirection directory.
|
||||||
|
fileRedirectionPath =
|
||||||
|
context.getExternalFilesDir(null)!!.canonicalPath + "/gpu/vk_file_redirect/"
|
||||||
|
|
||||||
|
// Initialize the driver installation directory.
|
||||||
|
driverInstallationPath = context.filesDir.canonicalPath + "/gpu_driver/"
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize directories.
|
||||||
|
initializeDirectories()
|
||||||
|
|
||||||
|
// Initialize hook libraries directory.
|
||||||
|
hookLibPath = context.applicationInfo.nativeLibraryDir + "/"
|
||||||
|
|
||||||
|
// Initialize GPU driver.
|
||||||
|
NativeLibrary.initializeGpuDriver(
|
||||||
|
hookLibPath,
|
||||||
|
driverInstallationPath,
|
||||||
|
customDriverLibraryName,
|
||||||
|
fileRedirectionPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installDefaultDriver(context: Context) {
|
||||||
|
// Removing the installed driver will result in the backend using the default system driver.
|
||||||
|
val driverInstallationDir = File(driverInstallationPath!!)
|
||||||
|
deleteRecursive(driverInstallationDir)
|
||||||
|
initializeDriverParameters(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installCustomDriver(context: Context, driverPathUri: Uri?) {
|
||||||
|
// Revert to system default in the event the specified driver is bad.
|
||||||
|
installDefaultDriver(context)
|
||||||
|
|
||||||
|
// Ensure we have directories.
|
||||||
|
initializeDirectories()
|
||||||
|
|
||||||
|
// Copy the zip file URI into our private storage.
|
||||||
|
copyUriToInternalStorage(
|
||||||
|
context,
|
||||||
|
driverPathUri,
|
||||||
|
driverInstallationPath!!,
|
||||||
|
DRIVER_INTERNAL_FILENAME
|
||||||
|
)
|
||||||
|
|
||||||
|
// Unzip the driver.
|
||||||
|
unzip(driverInstallationPath + DRIVER_INTERNAL_FILENAME, driverInstallationPath!!)
|
||||||
|
|
||||||
|
// Initialize the driver parameters.
|
||||||
|
initializeDriverParameters(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the custom driver metadata to retrieve the name.
|
||||||
|
val customDriverName: String?
|
||||||
|
get() {
|
||||||
|
val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
|
||||||
|
return metadata.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the custom driver metadata to retrieve the library name.
|
||||||
|
private val customDriverLibraryName: String?
|
||||||
|
get() {
|
||||||
|
// Parse the custom driver metadata to retrieve the library name.
|
||||||
|
val metadata = GpuDriverMetadata(driverInstallationPath + META_JSON_FILENAME)
|
||||||
|
return metadata.libraryName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeDirectories() {
|
||||||
|
// Ensure the file redirection directory exists.
|
||||||
|
val fileRedirectionDir = File(fileRedirectionPath!!)
|
||||||
|
if (!fileRedirectionDir.exists()) {
|
||||||
|
fileRedirectionDir.mkdirs()
|
||||||
|
}
|
||||||
|
// Ensure the driver installation directory exists.
|
||||||
|
val driverInstallationDir = File(driverInstallationPath!!)
|
||||||
|
if (!driverInstallationDir.exists()) {
|
||||||
|
driverInstallationDir.mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteRecursive(fileOrDirectory: File) {
|
||||||
|
if (fileOrDirectory.isDirectory) {
|
||||||
|
for (child in fileOrDirectory.listFiles()!!) {
|
||||||
|
deleteRecursive(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileOrDirectory.delete()
|
||||||
|
}
|
||||||
|
}
|
47
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
Executable file
47
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/GpuDriverMetadata.kt
Executable file
|
@ -0,0 +1,47 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
class GpuDriverMetadata(metadataFilePath: String) {
|
||||||
|
var name: String? = null
|
||||||
|
var description: String? = null
|
||||||
|
var author: String? = null
|
||||||
|
var vendor: String? = null
|
||||||
|
var driverVersion: String? = null
|
||||||
|
var minApi = 0
|
||||||
|
var libraryName: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(getStringFromFile(metadataFilePath))
|
||||||
|
name = json.getString("name")
|
||||||
|
description = json.getString("description")
|
||||||
|
author = json.getString("author")
|
||||||
|
vendor = json.getString("vendor")
|
||||||
|
driverVersion = json.getString("driverVersion")
|
||||||
|
minApi = json.getInt("minApi")
|
||||||
|
libraryName = json.getString("libraryName")
|
||||||
|
} catch (e: JSONException) {
|
||||||
|
// JSON is malformed, ignore and treat as unsupported metadata.
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// File is inaccessible, ignore and treat as unsupported metadata.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun getStringFromFile(filePath: String): String {
|
||||||
|
val path = Paths.get(filePath)
|
||||||
|
val bytes = Files.readAllBytes(path)
|
||||||
|
return String(bytes, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
360
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
Executable file
360
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InputHandler.kt
Executable file
|
@ -0,0 +1,360 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
class InputHandler {
|
||||||
|
fun initialize() {
|
||||||
|
// Connect first controller
|
||||||
|
NativeLibrary.onGamePadConnectEvent(getPlayerNumber(NativeLibrary.Player1Device))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
val button: Int = when (event.device.vendorId) {
|
||||||
|
0x045E -> getInputXboxButtonKey(event.keyCode)
|
||||||
|
0x054C -> getInputDS5ButtonKey(event.keyCode)
|
||||||
|
0x057E -> getInputJoyconButtonKey(event.keyCode)
|
||||||
|
0x1532 -> getInputRazerButtonKey(event.keyCode)
|
||||||
|
else -> getInputGenericButtonKey(event.keyCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
val action = when (event.action) {
|
||||||
|
KeyEvent.ACTION_DOWN -> NativeLibrary.ButtonState.PRESSED
|
||||||
|
KeyEvent.ACTION_UP -> NativeLibrary.ButtonState.RELEASED
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore invalid buttons
|
||||||
|
if (button < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return NativeLibrary.onGamePadButtonEvent(
|
||||||
|
getPlayerNumber(event.device.controllerNumber),
|
||||||
|
button,
|
||||||
|
action
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
|
val device = event.device
|
||||||
|
// Check every axis input available on the controller
|
||||||
|
for (range in device.motionRanges) {
|
||||||
|
val axis = range.axis
|
||||||
|
when (device.vendorId) {
|
||||||
|
0x045E -> setGenericAxisInput(event, axis)
|
||||||
|
0x054C -> setGenericAxisInput(event, axis)
|
||||||
|
0x057E -> setJoyconAxisInput(event, axis)
|
||||||
|
0x1532 -> setRazerAxisInput(event, axis)
|
||||||
|
else -> setGenericAxisInput(event, axis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPlayerNumber(index: Int): Int {
|
||||||
|
// TODO: Joycons are handled as different controllers. Find a way to merge them.
|
||||||
|
return when (index) {
|
||||||
|
2 -> NativeLibrary.Player2Device
|
||||||
|
3 -> NativeLibrary.Player3Device
|
||||||
|
4 -> NativeLibrary.Player4Device
|
||||||
|
5 -> NativeLibrary.Player5Device
|
||||||
|
6 -> NativeLibrary.Player6Device
|
||||||
|
7 -> NativeLibrary.Player7Device
|
||||||
|
8 -> NativeLibrary.Player8Device
|
||||||
|
else -> if (NativeLibrary.isHandheldOnly()) NativeLibrary.ConsoleDevice else NativeLibrary.Player1Device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setStickState(playerNumber: Int, index: Int, xAxis: Float, yAxis: Float) {
|
||||||
|
// Calculate vector size
|
||||||
|
val r2 = xAxis * xAxis + yAxis * yAxis
|
||||||
|
var r = sqrt(r2.toDouble()).toFloat()
|
||||||
|
|
||||||
|
// Adjust range of joystick
|
||||||
|
val deadzone = 0.15f
|
||||||
|
var x = xAxis
|
||||||
|
var y = yAxis
|
||||||
|
|
||||||
|
if (r > deadzone) {
|
||||||
|
val deadzoneFactor = 1.0f / r * (r - deadzone) / (1.0f - deadzone)
|
||||||
|
x *= deadzoneFactor
|
||||||
|
y *= deadzoneFactor
|
||||||
|
r *= deadzoneFactor
|
||||||
|
} else {
|
||||||
|
x = 0.0f
|
||||||
|
y = 0.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize joystick
|
||||||
|
if (r > 1.0f) {
|
||||||
|
x /= r
|
||||||
|
y /= r
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeLibrary.onGamePadJoystickEvent(
|
||||||
|
playerNumber,
|
||||||
|
index,
|
||||||
|
x,
|
||||||
|
-y
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getAxisToButton(axis: Float): Int {
|
||||||
|
return if (axis > 0.5f) NativeLibrary.ButtonState.PRESSED else NativeLibrary.ButtonState.RELEASED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAxisDpadState(playerNumber: Int, xAxis: Float, yAxis: Float) {
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.DPAD_UP,
|
||||||
|
getAxisToButton(-yAxis)
|
||||||
|
)
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.DPAD_DOWN,
|
||||||
|
getAxisToButton(yAxis)
|
||||||
|
)
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.DPAD_LEFT,
|
||||||
|
getAxisToButton(-xAxis)
|
||||||
|
)
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.DPAD_RIGHT,
|
||||||
|
getAxisToButton(xAxis)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInputDS5ButtonKey(key: Int): Int {
|
||||||
|
// The missing ds5 buttons are axis
|
||||||
|
return when (key) {
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInputJoyconButtonKey(key: Int): Int {
|
||||||
|
// Joycon support is half dead. A lot of buttons can't be mapped
|
||||||
|
return when (key) {
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInputXboxButtonKey(key: Int): Int {
|
||||||
|
// The missing xbox buttons are axis
|
||||||
|
return when (key) {
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInputRazerButtonKey(key: Int): Int {
|
||||||
|
// The missing xbox buttons are axis
|
||||||
|
return when (key) {
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_B
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_A
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_Y
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_X
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInputGenericButtonKey(key: Int): Int {
|
||||||
|
return when (key) {
|
||||||
|
KeyEvent.KEYCODE_BUTTON_A -> NativeLibrary.ButtonType.BUTTON_A
|
||||||
|
KeyEvent.KEYCODE_BUTTON_B -> NativeLibrary.ButtonType.BUTTON_B
|
||||||
|
KeyEvent.KEYCODE_BUTTON_X -> NativeLibrary.ButtonType.BUTTON_X
|
||||||
|
KeyEvent.KEYCODE_BUTTON_Y -> NativeLibrary.ButtonType.BUTTON_Y
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> NativeLibrary.ButtonType.DPAD_UP
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> NativeLibrary.ButtonType.DPAD_DOWN
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> NativeLibrary.ButtonType.DPAD_LEFT
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> NativeLibrary.ButtonType.DPAD_RIGHT
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L1 -> NativeLibrary.ButtonType.TRIGGER_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R1 -> NativeLibrary.ButtonType.TRIGGER_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_L2 -> NativeLibrary.ButtonType.TRIGGER_ZL
|
||||||
|
KeyEvent.KEYCODE_BUTTON_R2 -> NativeLibrary.ButtonType.TRIGGER_ZR
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBL -> NativeLibrary.ButtonType.STICK_L
|
||||||
|
KeyEvent.KEYCODE_BUTTON_THUMBR -> NativeLibrary.ButtonType.STICK_R
|
||||||
|
KeyEvent.KEYCODE_BUTTON_START -> NativeLibrary.ButtonType.BUTTON_PLUS
|
||||||
|
KeyEvent.KEYCODE_BUTTON_SELECT -> NativeLibrary.ButtonType.BUTTON_MINUS
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setGenericAxisInput(event: MotionEvent, axis: Int) {
|
||||||
|
val playerNumber = getPlayerNumber(event.device.controllerNumber)
|
||||||
|
|
||||||
|
when (axis) {
|
||||||
|
MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_L,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_X),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_R,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RX),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RY)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_R,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Z),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_LTRIGGER ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZL,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_LTRIGGER))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_BRAKE ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZL,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_RTRIGGER ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZR,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_RTRIGGER))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_GAS ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZR,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
|
||||||
|
setAxisDpadState(
|
||||||
|
playerNumber,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun setJoyconAxisInput(event: MotionEvent, axis: Int) {
|
||||||
|
// Joycon support is half dead. Right joystick doesn't work
|
||||||
|
val playerNumber = getPlayerNumber(event.device.controllerNumber)
|
||||||
|
|
||||||
|
when (axis) {
|
||||||
|
MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_L,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_X),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_R,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Z),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_RX, MotionEvent.AXIS_RY ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_R,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RX),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RY)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setRazerAxisInput(event: MotionEvent, axis: Int) {
|
||||||
|
val playerNumber = getPlayerNumber(event.device.controllerNumber)
|
||||||
|
|
||||||
|
when (axis) {
|
||||||
|
MotionEvent.AXIS_X, MotionEvent.AXIS_Y ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_L,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_X),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Y)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ ->
|
||||||
|
setStickState(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.StickType.STICK_R,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_Z),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_RZ)
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_BRAKE ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZL,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_BRAKE))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_GAS ->
|
||||||
|
NativeLibrary.onGamePadButtonEvent(
|
||||||
|
playerNumber,
|
||||||
|
NativeLibrary.ButtonType.TRIGGER_ZR,
|
||||||
|
getAxisToButton(event.getAxisValue(MotionEvent.AXIS_GAS))
|
||||||
|
)
|
||||||
|
MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y ->
|
||||||
|
setAxisDpadState(
|
||||||
|
playerNumber,
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_X),
|
||||||
|
event.getAxisValue(MotionEvent.AXIS_HAT_Y)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
31
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
Executable file
31
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/InsetsHelper.kt
Executable file
|
@ -0,0 +1,31 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
|
||||||
|
object InsetsHelper {
|
||||||
|
const val THREE_BUTTON_NAVIGATION = 0
|
||||||
|
const val TWO_BUTTON_NAVIGATION = 1
|
||||||
|
const val GESTURE_NAVIGATION = 2
|
||||||
|
|
||||||
|
@SuppressLint("DiscouragedApi")
|
||||||
|
fun getSystemGestureType(context: Context): Int {
|
||||||
|
val resources = context.resources
|
||||||
|
val resourceId =
|
||||||
|
resources.getIdentifier("config_navBarInteractionMode", "integer", "android")
|
||||||
|
return if (resourceId != 0) {
|
||||||
|
resources.getInteger(resourceId)
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBottomPaddingRequired(activity: Activity): Int {
|
||||||
|
val visibleFrame = Rect()
|
||||||
|
activity.window.decorView.getWindowVisibleDisplayFrame(visibleFrame)
|
||||||
|
return visibleFrame.bottom - visibleFrame.top - activity.resources.displayMetrics.heightPixels
|
||||||
|
}
|
||||||
|
}
|
40
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
Executable file
40
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/Log.kt
Executable file
|
@ -0,0 +1,40 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import org.yuzu.yuzu_emu.BuildConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains methods that call through to [android.util.Log], but
|
||||||
|
* with the same TAG automatically provided. Also no-ops VERBOSE and DEBUG log
|
||||||
|
* levels in release builds.
|
||||||
|
*/
|
||||||
|
object Log {
|
||||||
|
private const val TAG = "Yuzu Frontend"
|
||||||
|
|
||||||
|
fun verbose(message: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.v(TAG, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun debug(message: String) {
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Log.d(TAG, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun info(message: String) {
|
||||||
|
Log.i(TAG, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun warning(message: String) {
|
||||||
|
Log.w(TAG, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun error(message: String) {
|
||||||
|
Log.e(TAG, message)
|
||||||
|
}
|
||||||
|
}
|
168
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
Executable file
168
src/android/app/src/main/java/org/yuzu/yuzu_emu/utils/NfcReader.kt
Executable file
|
@ -0,0 +1,168 @@
|
||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.nfc.NfcAdapter
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.NfcA
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class NfcReader(private val activity: Activity) {
|
||||||
|
private var nfcAdapter: NfcAdapter? = null
|
||||||
|
private var pendingIntent: PendingIntent? = null
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
nfcAdapter = NfcAdapter.getDefaultAdapter(activity) ?: return
|
||||||
|
|
||||||
|
pendingIntent = PendingIntent.getActivity(
|
||||||
|
activity,
|
||||||
|
0, Intent(activity, activity.javaClass),
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
else PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val tagDetected = IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
|
||||||
|
tagDetected.addCategory(Intent.CATEGORY_DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startScanning() {
|
||||||
|
nfcAdapter?.enableForegroundDispatch(activity, pendingIntent, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopScanning() {
|
||||||
|
nfcAdapter?.disableForegroundDispatch(activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onNewIntent(intent: Intent) {
|
||||||
|
val action = intent.action
|
||||||
|
if (NfcAdapter.ACTION_TAG_DISCOVERED != action
|
||||||
|
&& NfcAdapter.ACTION_TECH_DISCOVERED != action
|
||||||
|
&& NfcAdapter.ACTION_NDEF_DISCOVERED != action
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val tag =
|
||||||
|
intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return
|
||||||
|
readTagData(tag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val tag =
|
||||||
|
intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) ?: return
|
||||||
|
readTagData(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readTagData(tag: Tag) {
|
||||||
|
if (!tag.techList.contains("android.nfc.tech.NfcA")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val amiibo = NfcA.get(tag) ?: return
|
||||||
|
amiibo.connect()
|
||||||
|
|
||||||
|
val tagData = ntag215ReadAll(amiibo) ?: return
|
||||||
|
NativeLibrary.onReadNfcTag(tagData)
|
||||||
|
|
||||||
|
nfcAdapter?.ignore(
|
||||||
|
tag,
|
||||||
|
1000,
|
||||||
|
{ NativeLibrary.onRemoveNfcTag() },
|
||||||
|
Handler(Looper.getMainLooper())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntag215ReadAll(amiibo: NfcA): ByteArray? {
|
||||||
|
val bufferSize = amiibo.maxTransceiveLength;
|
||||||
|
val tagSize = 0x21C
|
||||||
|
val pageSize = 4
|
||||||
|
val lastPage = tagSize / pageSize - 1
|
||||||
|
val tagData = ByteArray(tagSize)
|
||||||
|
|
||||||
|
// We need to read the ntag in steps otherwise we overflow the buffer
|
||||||
|
for (i in 0..tagSize step bufferSize - 1) {
|
||||||
|
val dataStart = i / pageSize
|
||||||
|
var dataEnd = (i + bufferSize) / pageSize
|
||||||
|
|
||||||
|
if (dataEnd > lastPage) {
|
||||||
|
dataEnd = lastPage
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val data = ntag215FastRead(amiibo, dataStart, dataEnd - 1)
|
||||||
|
System.arraycopy(data, 0, tagData, i, (dataEnd - dataStart) * pageSize)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tagData
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntag215Read(amiibo: NfcA, page: Int): ByteArray? {
|
||||||
|
return amiibo.transceive(
|
||||||
|
byteArrayOf(
|
||||||
|
0x30.toByte(),
|
||||||
|
(page and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntag215FastRead(amiibo: NfcA, start: Int, end: Int): ByteArray? {
|
||||||
|
return amiibo.transceive(
|
||||||
|
byteArrayOf(
|
||||||
|
0x3A.toByte(),
|
||||||
|
(start and 0xFF).toByte(),
|
||||||
|
(end and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntag215PWrite(
|
||||||
|
amiibo: NfcA,
|
||||||
|
page: Int,
|
||||||
|
data1: Int,
|
||||||
|
data2: Int,
|
||||||
|
data3: Int,
|
||||||
|
data4: Int
|
||||||
|
): ByteArray? {
|
||||||
|
return amiibo.transceive(
|
||||||
|
byteArrayOf(
|
||||||
|
0xA2.toByte(),
|
||||||
|
(page and 0xFF).toByte(),
|
||||||
|
(data1 and 0xFF).toByte(),
|
||||||
|
(data2 and 0xFF).toByte(),
|
||||||
|
(data3 and 0xFF).toByte(),
|
||||||
|
(data4 and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ntag215PwdAuth(
|
||||||
|
amiibo: NfcA,
|
||||||
|
data1: Int,
|
||||||
|
data2: Int,
|
||||||
|
data3: Int,
|
||||||
|
data4: Int
|
||||||
|
): ByteArray? {
|
||||||
|
return amiibo.transceive(
|
||||||
|
byteArrayOf(
|
||||||
|
0x1B.toByte(),
|
||||||
|
(data1 and 0xFF).toByte(),
|
||||||
|
(data2 and 0xFF).toByte(),
|
||||||
|
(data3 and 0xFF).toByte(),
|
||||||
|
(data4 and 0xFF).toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue