mirror of https://git.suyu.dev/suyu/suyu
Merge pull request #10508 from yuzu-emu/lime
Project Lime - yuzu Android Portmerge-requests/60/head
commit
cb95d7fe1b
@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash -ex
|
||||||
|
|
||||||
|
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
export NDK_CCACHE="$(which ccache)"
|
||||||
|
ccache -s
|
||||||
|
|
||||||
|
BUILD_FLAVOR=mainline
|
||||||
|
|
||||||
|
cd src/android
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew "assemble${BUILD_FLAVOR}Release" "bundle${BUILD_FLAVOR}Release"
|
||||||
|
|
||||||
|
ccache -s
|
@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash -ex
|
||||||
|
|
||||||
|
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
. ./.ci/scripts/common/pre-upload.sh
|
||||||
|
|
||||||
|
REV_NAME="yuzu-${GITDATE}-${GITREV}"
|
||||||
|
|
||||||
|
BUILD_FLAVOR=mainline
|
||||||
|
|
||||||
|
cp src/android/app/build/outputs/apk/"${BUILD_FLAVOR}/release/app-${BUILD_FLAVOR}-release.apk" \
|
||||||
|
"artifacts/${REV_NAME}.apk"
|
||||||
|
cp src/android/app/build/outputs/bundle/"${BUILD_FLAVOR}Release"/"app-${BUILD_FLAVOR}-release.aab" \
|
||||||
|
"artifacts/${REV_NAME}.aab"
|
||||||
|
|
||||||
|
if [ -n "${ANDROID_KEYSTORE_B64}" ]
|
||||||
|
then
|
||||||
|
echo "Signing apk..."
|
||||||
|
base64 --decode <<< "${ANDROID_KEYSTORE_B64}" > ks.jks
|
||||||
|
|
||||||
|
apksigner sign --ks ks.jks \
|
||||||
|
--ks-key-alias "${ANDROID_KEY_ALIAS}" \
|
||||||
|
--ks-pass env:ANDROID_KEYSTORE_PASS "artifacts/${REV_NAME}.apk"
|
||||||
|
else
|
||||||
|
echo "No keystore specified, not signing the APK files."
|
||||||
|
fi
|
@ -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.
|
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 5cd3f5c5ceea6d9e9d435ccdd922d9b99e55d10b
|
@ -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
|
@ -0,0 +1,248 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
kotlin("plugin.serialization") version "1.8.21"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use the number of seconds/10 since Jan 1 2016 as the versionCode.
|
||||||
|
* This lets us upload a new build at most every 10 seconds for the
|
||||||
|
* next 680 years.
|
||||||
|
*/
|
||||||
|
val autoVersion = (((System.currentTimeMillis() / 1000) - 1451606400) / 10).toInt()
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
android {
|
||||||
|
namespace = "org.yuzu.yuzu_emu"
|
||||||
|
|
||||||
|
compileSdkVersion = "android-33"
|
||||||
|
ndkVersion = "25.2.9519653"
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
// This is necessary for libadrenotools custom driver loading
|
||||||
|
jniLibs.useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// This is important as it will run lint but not abort on error
|
||||||
|
// Lint has some overly obnoxious "errors" that should really be warnings
|
||||||
|
abortOnError = false
|
||||||
|
|
||||||
|
//Uncomment disable lines for test builds...
|
||||||
|
//disable 'MissingTranslation'bin
|
||||||
|
//disable 'ExtraTranslation'
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO If this is ever modified, change application_id in strings.xml
|
||||||
|
applicationId = "org.yuzu.yuzu_emu"
|
||||||
|
minSdk = 30
|
||||||
|
targetSdk = 33
|
||||||
|
versionName = getGitVersion()
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
@SuppressLint("ChromeOsAbiSupport")
|
||||||
|
abiFilters += listOf("arm64-v8a")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
|
||||||
|
buildConfigField("String", "BRANCH", "\"${getBranch()}\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define build types, which are orthogonal to product flavors.
|
||||||
|
buildTypes {
|
||||||
|
|
||||||
|
// Signed by release key, allowing for upload to Play Store.
|
||||||
|
release {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isDebuggable = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
register("relWithVersionCode") {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isDebuggable = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// builds a release build that doesn't need signing
|
||||||
|
// Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
|
||||||
|
register("relWithDebInfo") {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isDebuggable = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
isJniDebuggable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed by debug key disallowing distribution on Play Store.
|
||||||
|
// Attaches 'debug' suffix to version and package name, allowing installation alongside the release build.
|
||||||
|
debug {
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flavorDimensions.add("version")
|
||||||
|
productFlavors {
|
||||||
|
create("mainline") {
|
||||||
|
dimension = "version"
|
||||||
|
buildConfigField("Boolean", "PREMIUM", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
create("ea") {
|
||||||
|
dimension = "version"
|
||||||
|
buildConfigField("Boolean", "PREMIUM", "true")
|
||||||
|
applicationIdSuffix = ".ea"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
version = "3.22.1"
|
||||||
|
path = file("../../../CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments(
|
||||||
|
"-DENABLE_QT=0", // Don't use QT
|
||||||
|
"-DENABLE_SDL2=0", // Don't use SDL
|
||||||
|
"-DENABLE_WEB_SERVICE=0", // Don't use telemetry
|
||||||
|
"-DBUNDLE_SPEEX=ON",
|
||||||
|
"-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work
|
||||||
|
"-DYUZU_USE_BUNDLED_VCPKG=ON",
|
||||||
|
"-DYUZU_USE_BUNDLED_FFMPEG=ON",
|
||||||
|
"-DYUZU_ENABLE_LTO=ON"
|
||||||
|
)
|
||||||
|
|
||||||
|
abiFilters("arm64-v8a", "x86_64")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.core:core-ktx:1.10.1")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||||
|
implementation("androidx.recyclerview:recyclerview:1.3.0")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
|
implementation("androidx.fragment:fragment-ktx:1.5.7")
|
||||||
|
implementation("androidx.documentfile:documentfile:1.0.1")
|
||||||
|
implementation("com.google.android.material:material:1.9.0")
|
||||||
|
implementation("androidx.preference:preference:1.2.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
|
||||||
|
implementation("io.coil-kt:coil:2.2.2")
|
||||||
|
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||||
|
implementation("androidx.window:window:1.0.0")
|
||||||
|
implementation("org.ini4j:ini4j:0.5.4")
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
implementation("androidx.navigation:navigation-fragment-ktx:2.5.3")
|
||||||
|
implementation("androidx.navigation:navigation-ui-ktx:2.5.3")
|
||||||
|
implementation("info.debatty:java-string-similarity:2.0.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGitVersion(): String {
|
||||||
|
var versionName = "0.0"
|
||||||
|
|
||||||
|
try {
|
||||||
|
versionName = ProcessBuilder("git", "describe", "--always", "--long")
|
||||||
|
.directory(project.rootDir)
|
||||||
|
.redirectOutput(ProcessBuilder.Redirect.PIPE)
|
||||||
|
.redirectError(ProcessBuilder.Redirect.PIPE)
|
||||||
|
.start().inputStream.bufferedReader().use { it.readText() }
|
||||||
|
.trim()
|
||||||
|
.replace(Regex("(-0)?-[^-]+$"), "")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Cannot find git, defaulting to dummy version number")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (System.getenv("GITHUB_ACTIONS") != null) {
|
||||||
|
val gitTag = System.getenv("GIT_TAG_NAME")
|
||||||
|
versionName = gitTag ?: versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGitHash(): String {
|
||||||
|
try {
|
||||||
|
val processBuilder = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
|
||||||
|
processBuilder.directory(project.rootDir)
|
||||||
|
val process = processBuilder.start()
|
||||||
|
val inputStream = process.inputStream
|
||||||
|
val errorStream = process.errorStream
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
return if (process.exitValue() == 0) {
|
||||||
|
inputStream.bufferedReader()
|
||||||
|
.use { it.readText().trim() } // return the value of gitHash
|
||||||
|
} else {
|
||||||
|
val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
|
||||||
|
logger.error("Error running git command: $errorMessage")
|
||||||
|
"dummy-hash" // return a dummy hash value in case of an error
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("$e: Cannot find git, defaulting to dummy build hash")
|
||||||
|
return "dummy-hash" // return a dummy hash value in case of an error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBranch(): String {
|
||||||
|
try {
|
||||||
|
val processBuilder = ProcessBuilder("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
processBuilder.directory(project.rootDir)
|
||||||
|
val process = processBuilder.start()
|
||||||
|
val inputStream = process.inputStream
|
||||||
|
val errorStream = process.errorStream
|
||||||
|
process.waitFor()
|
||||||
|
|
||||||
|
return if (process.exitValue() == 0) {
|
||||||
|
inputStream.bufferedReader()
|
||||||
|
.use { it.readText().trim() } // return the value of gitHash
|
||||||
|
} else {
|
||||||
|
val errorMessage = errorStream.bufferedReader().use { it.readText().trim() }
|
||||||
|
logger.error("Error running git command: $errorMessage")
|
||||||
|
"dummy-hash" // return a dummy hash value in case of an error
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("$e: Cannot find git, defaulting to dummy build hash")
|
||||||
|
return "dummy-hash" // return a dummy hash value in case of an error
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -0,0 +1,508 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Html
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication.Companion.appContext
|
||||||
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
|
import org.yuzu.yuzu_emu.utils.DocumentsTree.Companion.isNativePath
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil.getFileSize
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil.openContentUri
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log.error
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log.verbose
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log.warning
|
||||||
|
import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class which contains methods that interact
|
||||||
|
* with the native side of the Yuzu code.
|
||||||
|
*/
|
||||||
|
object NativeLibrary {
|
||||||
|
/**
|
||||||
|
* Default controller id for each device
|
||||||
|
*/
|
||||||
|
const val Player1Device = 0
|
||||||
|
const val Player2Device = 1
|
||||||
|
const val Player3Device = 2
|
||||||
|
const val Player4Device = 3
|
||||||
|
const val Player5Device = 4
|
||||||
|
const val Player6Device = 5
|
||||||
|
const val Player7Device = 6
|
||||||
|
const val Player8Device = 7
|
||||||
|
const val ConsoleDevice = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller type for each device
|
||||||
|
*/
|
||||||
|
const val ProController = 3
|
||||||
|
const val Handheld = 4
|
||||||
|
const val JoyconDual = 5
|
||||||
|
const val JoyconLeft = 6
|
||||||
|
const val JoyconRight = 7
|
||||||
|
const val GameCube = 8
|
||||||
|
const val Pokeball = 9
|
||||||
|
const val NES = 10
|
||||||
|
const val SNES = 11
|
||||||
|
const val N64 = 12
|
||||||
|
const val SegaGenesis = 13
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
var sEmulationActivity = WeakReference<EmulationActivity?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
System.loadLibrary("yuzu-android")
|
||||||
|
} catch (ex: UnsatisfiedLinkError) {
|
||||||
|
error("[NativeLibrary] $ex")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
@JvmStatic
|
||||||
|
fun openContentUri(path: String?, openmode: String?): Int {
|
||||||
|
return if (isNativePath(path!!)) {
|
||||||
|
YuzuApplication.documentsTree!!.openContentUri(path, openmode)
|
||||||
|
} else openContentUri(appContext, path, openmode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
@JvmStatic
|
||||||
|
fun getSize(path: String?): Long {
|
||||||
|
return if (isNativePath(path!!)) {
|
||||||
|
YuzuApplication.documentsTree!!.getFileSize(path)
|
||||||
|
} else getFileSize(appContext, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if pro controller isn't available and handheld is
|
||||||
|
*/
|
||||||
|
external fun isHandheldOnly(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes controller type for a specific device.
|
||||||
|
*
|
||||||
|
* @param Device The input descriptor of the gamepad.
|
||||||
|
* @param Type The NpadStyleIndex of the gamepad.
|
||||||
|
*/
|
||||||
|
external fun setDeviceType(Device: Int, Type: Int): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles event when a gamepad is connected.
|
||||||
|
*
|
||||||
|
* @param Device The input descriptor of the gamepad.
|
||||||
|
*/
|
||||||
|
external fun onGamePadConnectEvent(Device: Int): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles event when a gamepad is disconnected.
|
||||||
|
*
|
||||||
|
* @param Device The input descriptor of the gamepad.
|
||||||
|
*/
|
||||||
|
external fun onGamePadDisconnectEvent(Device: Int): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles button press events for a gamepad.
|
||||||
|
*
|
||||||
|
* @param Device The input descriptor of the gamepad.
|
||||||
|
* @param Button Key code identifying which button was pressed.
|
||||||
|
* @param Action Mask identifying which action is happening (button pressed down, or button released).
|
||||||
|
* @return If we handled the button press.
|
||||||
|
*/
|
||||||
|
external fun onGamePadButtonEvent(Device: Int, Button: Int, Action: Int): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles joystick movement events.
|
||||||
|
*
|
||||||
|
* @param Device The device ID of the gamepad.
|
||||||
|
* @param Axis The axis ID
|
||||||
|
* @param x_axis The value of the x-axis represented by the given ID.
|
||||||
|
* @param y_axis The value of the y-axis represented by the given ID.
|
||||||
|
*/
|
||||||
|
external fun onGamePadJoystickEvent(
|
||||||
|
Device: Int,
|
||||||
|
Axis: Int,
|
||||||
|
x_axis: Float,
|
||||||
|
y_axis: Float
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles motion events.
|
||||||
|
*
|
||||||
|
* @param delta_timestamp The finger id corresponding to this event
|
||||||
|
* @param gyro_x,gyro_y,gyro_z The value of the accelerometer sensor.
|
||||||
|
* @param accel_x,accel_y,accel_z The value of the y-axis
|
||||||
|
*/
|
||||||
|
external fun onGamePadMotionEvent(
|
||||||
|
Device: Int,
|
||||||
|
delta_timestamp: Long,
|
||||||
|
gyro_x: Float,
|
||||||
|
gyro_y: Float,
|
||||||
|
gyro_z: Float,
|
||||||
|
accel_x: Float,
|
||||||
|
accel_y: Float,
|
||||||
|
accel_z: Float
|
||||||
|
): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals and load a nfc tag
|
||||||
|
*
|
||||||
|
* @param data Byte array containing all the data from a nfc tag
|
||||||
|
*/
|
||||||
|
external fun onReadNfcTag(data: ByteArray?): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes current loaded nfc tag
|
||||||
|
*/
|
||||||
|
external fun onRemoveNfcTag(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles touch press events.
|
||||||
|
*
|
||||||
|
* @param finger_id The finger id corresponding to this event
|
||||||
|
* @param x_axis The value of the x-axis.
|
||||||
|
* @param y_axis The value of the y-axis.
|
||||||
|
*/
|
||||||
|
external fun onTouchPressed(finger_id: Int, x_axis: Float, y_axis: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles touch movement.
|
||||||
|
*
|
||||||
|
* @param x_axis The value of the instantaneous x-axis.
|
||||||
|
* @param y_axis The value of the instantaneous y-axis.
|
||||||
|
*/
|
||||||
|
external fun onTouchMoved(finger_id: Int, x_axis: Float, y_axis: Float)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles touch release events.
|
||||||
|
*
|
||||||
|
* @param finger_id The finger id corresponding to this event
|
||||||
|
*/
|
||||||
|
external fun onTouchReleased(finger_id: Int)
|
||||||
|
|
||||||
|
external fun reloadSettings()
|
||||||
|
|
||||||
|
external fun getUserSetting(gameID: String?, Section: String?, Key: String?): String?
|
||||||
|
|
||||||
|
external fun setUserSetting(gameID: String?, Section: String?, Key: String?, Value: String?)
|
||||||
|
|
||||||
|
external fun initGameIni(gameID: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the embedded icon within the given ROM.
|
||||||
|
*
|
||||||
|
* @param filename the file path to the ROM.
|
||||||
|
* @return a byte array containing the JPEG data for the icon.
|
||||||
|
*/
|
||||||
|
external fun getIcon(filename: String): ByteArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the embedded title of the given ISO/ROM.
|
||||||
|
*
|
||||||
|
* @param filename The file path to the ISO/ROM.
|
||||||
|
* @return the embedded title of the ISO/ROM.
|
||||||
|
*/
|
||||||
|
external fun getTitle(filename: String): String
|
||||||
|
|
||||||
|
external fun getDescription(filename: String): String
|
||||||
|
|
||||||
|
external fun getGameId(filename: String): String
|
||||||
|
|
||||||
|
external fun getRegions(filename: String): String
|
||||||
|
|
||||||
|
external fun getCompany(filename: String): String
|
||||||
|
|
||||||
|
external fun setAppDirectory(directory: String)
|
||||||
|
|
||||||
|
external fun initializeGpuDriver(
|
||||||
|
hookLibDir: String?,
|
||||||
|
customDriverDir: String?,
|
||||||
|
customDriverName: String?,
|
||||||
|
fileRedirectDir: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
external fun reloadKeys(): Boolean
|
||||||
|
|
||||||
|
external fun initializeEmulation()
|
||||||
|
|
||||||
|
external fun defaultCPUCore(): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins emulation.
|
||||||
|
*/
|
||||||
|
external fun run(path: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins emulation from the specified savestate.
|
||||||
|
*/
|
||||||
|
external fun run(path: String?, savestatePath: String?, deleteSavestate: Boolean)
|
||||||
|
|
||||||
|
// Surface Handling
|
||||||
|
external fun surfaceChanged(surf: Surface?)
|
||||||
|
|
||||||
|
external fun surfaceDestroyed()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpauses emulation from a paused state.
|
||||||
|
*/
|
||||||
|
external fun unPauseEmulation()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pauses emulation.
|
||||||
|
*/
|
||||||
|
external fun pauseEmulation()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops emulation.
|
||||||
|
*/
|
||||||
|
external fun stopEmulation()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the in-memory ROM metadata cache.
|
||||||
|
*/
|
||||||
|
external fun resetRomMetadata()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if emulation is running (or is paused).
|
||||||
|
*/
|
||||||
|
external fun isRunning(): Boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the performance stats for the current game
|
||||||
|
*/
|
||||||
|
external fun getPerfStats(): DoubleArray
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies the core emulation that the orientation has changed.
|
||||||
|
*/
|
||||||
|
external fun notifyOrientationChange(layout_option: Int, rotation: Int)
|
||||||
|
|
||||||
|
enum class CoreError {
|
||||||
|
ErrorSystemFiles,
|
||||||
|
ErrorSavestate,
|
||||||
|
ErrorUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coreErrorAlertResult = false
|
||||||
|
private val coreErrorAlertLock = Object()
|
||||||
|
|
||||||
|
class CoreErrorDialogFragment : DialogFragment() {
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val title = requireArguments().serializable<String>("title")
|
||||||
|
val message = requireArguments().serializable<String>("message")
|
||||||
|
|
||||||
|
return MaterialAlertDialogBuilder(requireActivity())
|
||||||
|
.setTitle(title)
|
||||||
|
.setMessage(message)
|
||||||
|
.setPositiveButton(R.string.continue_button, null)
|
||||||
|
.setNegativeButton(R.string.abort_button) { _: DialogInterface?, _: Int ->
|
||||||
|
coreErrorAlertResult = false
|
||||||
|
synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
coreErrorAlertResult = true
|
||||||
|
synchronized(coreErrorAlertLock) { coreErrorAlertLock.notify() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun newInstance(title: String?, message: String?): CoreErrorDialogFragment {
|
||||||
|
val frag = CoreErrorDialogFragment()
|
||||||
|
val args = Bundle()
|
||||||
|
args.putString("title", title)
|
||||||
|
args.putString("message", message)
|
||||||
|
frag.arguments = args
|
||||||
|
return frag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCoreErrorImpl(title: String, message: String) {
|
||||||
|
val emulationActivity = sEmulationActivity.get()
|
||||||
|
if (emulationActivity == null) {
|
||||||
|
error("[NativeLibrary] EmulationActivity not present")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val fragment = CoreErrorDialogFragment.newInstance(title, message)
|
||||||
|
fragment.show(emulationActivity.supportFragmentManager, "coreError")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a core error.
|
||||||
|
*
|
||||||
|
* @return true: continue; false: abort
|
||||||
|
*/
|
||||||
|
fun onCoreError(error: CoreError?, details: String): Boolean {
|
||||||
|
val emulationActivity = sEmulationActivity.get()
|
||||||
|
if (emulationActivity == null) {
|
||||||
|
error("[NativeLibrary] EmulationActivity not present")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val title: String
|
||||||
|
val message: String
|
||||||
|
when (error) {
|
||||||
|
CoreError.ErrorSystemFiles -> {
|
||||||
|
title = emulationActivity.getString(R.string.system_archive_not_found)
|
||||||
|
message = emulationActivity.getString(
|
||||||
|
R.string.system_archive_not_found_message,
|
||||||
|
details.ifEmpty { emulationActivity.getString(R.string.system_archive_general) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CoreError.ErrorSavestate -> {
|
||||||
|
title = emulationActivity.getString(R.string.save_load_error)
|
||||||
|
message = details
|
||||||
|
}
|
||||||
|
CoreError.ErrorUnknown -> {
|
||||||
|
title = emulationActivity.getString(R.string.fatal_error)
|
||||||
|
message = emulationActivity.getString(R.string.fatal_error_message)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the AlertDialog on the main thread.
|
||||||
|
emulationActivity.runOnUiThread(Runnable { onCoreErrorImpl(title, message) })
|
||||||
|
|
||||||
|
// Wait for the lock to notify that it is complete.
|
||||||
|
synchronized(coreErrorAlertLock) { coreErrorAlertLock.wait() }
|
||||||
|
|
||||||
|
return coreErrorAlertResult
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
@JvmStatic
|
||||||
|
fun exitEmulationActivity(resultCode: Int) {
|
||||||
|
val Success = 0
|
||||||
|
val ErrorNotInitialized = 1
|
||||||
|
val ErrorGetLoader = 2
|
||||||
|
val ErrorSystemFiles = 3
|
||||||
|
val ErrorSharedFont = 4
|
||||||
|
val ErrorVideoCore = 5
|
||||||
|
val ErrorUnknown = 6
|
||||||
|
val ErrorLoader = 7
|
||||||
|
|
||||||
|
val captionId: Int
|
||||||
|
var descriptionId: Int
|
||||||
|
when (resultCode) {
|
||||||
|
ErrorVideoCore -> {
|
||||||
|
captionId = R.string.loader_error_video_core
|
||||||
|
descriptionId = R.string.loader_error_video_core_description
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
captionId = R.string.loader_error_encrypted
|
||||||
|
descriptionId = R.string.loader_error_encrypted_roms_description
|
||||||
|
if (!reloadKeys()) {
|
||||||
|
descriptionId = R.string.loader_error_encrypted_keys_description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val emulationActivity = sEmulationActivity.get()
|
||||||
|
if (emulationActivity == null) {
|
||||||
|
warning("[NativeLibrary] EmulationActivity is null, can't exit.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = MaterialAlertDialogBuilder(emulationActivity)
|
||||||
|
.setTitle(captionId)
|
||||||
|
.setMessage(
|
||||||
|
Html.fromHtml(
|
||||||
|
emulationActivity.getString(descriptionId),
|
||||||
|
Html.FROM_HTML_MODE_LEGACY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> emulationActivity.finish() }
|
||||||
|
.setOnDismissListener { emulationActivity.finish() }
|
||||||
|
emulationActivity.runOnUiThread {
|
||||||
|
val alert = builder.create()
|
||||||
|
alert.show()
|
||||||
|
(alert.findViewById<View>(android.R.id.message) as TextView).movementMethod =
|
||||||
|
LinkMovementMethod.getInstance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEmulationActivity(emulationActivity: EmulationActivity?) {
|
||||||
|
verbose("[NativeLibrary] Registering EmulationActivity.")
|
||||||
|
sEmulationActivity = WeakReference(emulationActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearEmulationActivity() {
|
||||||
|
verbose("[NativeLibrary] Unregistering EmulationActivity.")
|
||||||
|
sEmulationActivity.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs the Yuzu version, Android version and, CPU.
|
||||||
|
*/
|
||||||
|
external fun logDeviceInfo()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits inline keyboard text. Called on input for buttons that result text.
|
||||||
|
* @param text Text to submit to the inline software keyboard implementation.
|
||||||
|
*/
|
||||||
|
external fun submitInlineKeyboardText(text: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits inline keyboard input. Used to indicate keys pressed that are not text.
|
||||||
|
* @param key_code Android Key Code associated with the keyboard input.
|
||||||
|
*/
|
||||||
|
external fun submitInlineKeyboardInput(key_code: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button type for use in onTouchEvent
|
||||||
|
*/
|
||||||
|
object ButtonType {
|
||||||
|
const val BUTTON_A = 0
|
||||||
|
const val BUTTON_B = 1
|
||||||
|
const val BUTTON_X = 2
|
||||||
|
const val BUTTON_Y = 3
|
||||||
|
const val STICK_L = 4
|
||||||
|
const val STICK_R = 5
|
||||||
|
const val TRIGGER_L = 6
|
||||||
|
const val TRIGGER_R = 7
|
||||||
|
const val TRIGGER_ZL = 8
|
||||||
|
const val TRIGGER_ZR = 9
|
||||||
|
const val BUTTON_PLUS = 10
|
||||||
|
const val BUTTON_MINUS = 11
|
||||||
|
const val DPAD_LEFT = 12
|
||||||
|
const val DPAD_UP = 13
|
||||||
|
const val DPAD_RIGHT = 14
|
||||||
|
const val DPAD_DOWN = 15
|
||||||
|
const val BUTTON_SL = 16
|
||||||
|
const val BUTTON_SR = 17
|
||||||
|
const val BUTTON_HOME = 18
|
||||||
|
const val BUTTON_CAPTURE = 19
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stick type for use in onTouchEvent
|
||||||
|
*/
|
||||||
|
object StickType {
|
||||||
|
const val STICK_L = 0
|
||||||
|
const val STICK_R = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button states
|
||||||
|
*/
|
||||||
|
object ButtonState {
|
||||||
|
const val RELEASED = 0
|
||||||
|
const val PRESSED = 1
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
|
import org.yuzu.yuzu_emu.utils.DocumentsTree
|
||||||
|
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun Context.getPublicFilesDir() : File = getExternalFilesDir(null) ?: filesDir
|
||||||
|
|
||||||
|
class YuzuApplication : Application() {
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val emulationChannel = NotificationChannel(
|
||||||
|
getString(R.string.emulation_notification_channel_id),
|
||||||
|
getString(R.string.emulation_notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
emulationChannel.description = getString(R.string.emulation_notification_channel_description)
|
||||||
|
emulationChannel.setSound(null, null)
|
||||||
|
emulationChannel.vibrationPattern = null
|
||||||
|
|
||||||
|
val noticeChannel = NotificationChannel(
|
||||||
|
getString(R.string.notice_notification_channel_id),
|
||||||
|
getString(R.string.notice_notification_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
noticeChannel.description = getString(R.string.notice_notification_channel_description)
|
||||||
|
noticeChannel.setSound(null, null)
|
||||||
|
|
||||||
|
// Register the channel with the system; you can't change the importance
|
||||||
|
// or other notification behaviors after this
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
notificationManager.createNotificationChannel(emulationChannel)
|
||||||
|
notificationManager.createNotificationChannel(noticeChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
application = this
|
||||||
|
documentsTree = DocumentsTree()
|
||||||
|
DirectoryInitialization.start(applicationContext)
|
||||||
|
GpuDriverHelper.initializeDriverParameters(applicationContext)
|
||||||
|
NativeLibrary.logDeviceInfo()
|
||||||
|
|
||||||
|
createNotificationChannels();
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var documentsTree: DocumentsTree? = null
|
||||||
|
lateinit var application: YuzuApplication
|
||||||
|
|
||||||
|
val appContext: Context
|
||||||
|
get() = application.applicationContext
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,333 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.activities
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.hardware.Sensor
|
||||||
|
import android.hardware.SensorEvent
|
||||||
|
import android.hardware.SensorEventListener
|
||||||
|
import android.hardware.SensorManager
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Display
|
||||||
|
import android.view.InputDevice
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.Surface
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.window.layout.WindowInfoTracker
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
|
||||||
|
import org.yuzu.yuzu_emu.fragments.EmulationFragment
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import org.yuzu.yuzu_emu.utils.ControllerMappingHelper
|
||||||
|
import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
|
||||||
|
import org.yuzu.yuzu_emu.utils.ForegroundService
|
||||||
|
import org.yuzu.yuzu_emu.utils.InputHandler
|
||||||
|
import org.yuzu.yuzu_emu.utils.NfcReader
|
||||||
|
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
|
||||||
|
import org.yuzu.yuzu_emu.utils.ThemeHelper
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class EmulationActivity : AppCompatActivity(), SensorEventListener {
|
||||||
|
private var controllerMappingHelper: ControllerMappingHelper? = null
|
||||||
|
|
||||||
|
var isActivityRecreated = false
|
||||||
|
private var emulationFragment: EmulationFragment? = null
|
||||||
|
private lateinit var nfcReader: NfcReader
|
||||||
|
private lateinit var inputHandler: InputHandler
|
||||||
|
|
||||||
|
private val gyro = FloatArray(3)
|
||||||
|
private val accel = FloatArray(3)
|
||||||
|
private var motionTimestamp: Long = 0
|
||||||
|
private var flipMotionOrientation: Boolean = false
|
||||||
|
|
||||||
|
private lateinit var game: Game
|
||||||
|
|
||||||
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopForegroundService(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.setTheme(this)
|
||||||
|
|
||||||
|
settingsViewModel.settings.loadSettings()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
// Get params we were passed
|
||||||
|
game = intent.parcelable(EXTRA_SELECTED_GAME)!!
|
||||||
|
isActivityRecreated = false
|
||||||
|
} else {
|
||||||
|
isActivityRecreated = true
|
||||||
|
restoreState(savedInstanceState)
|
||||||
|
}
|
||||||
|
controllerMappingHelper = ControllerMappingHelper()
|
||||||
|
|
||||||
|
// Set these options now so that the SurfaceView the game renders into is the right size.
|
||||||
|
enableFullscreenImmersive()
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_emulation)
|
||||||
|
window.decorView.setBackgroundColor(getColor(android.R.color.black))
|
||||||
|
|
||||||
|
// Find or create the EmulationFragment
|
||||||
|
emulationFragment =
|
||||||
|
supportFragmentManager.findFragmentById(R.id.frame_emulation_fragment) as EmulationFragment?
|
||||||
|
if (emulationFragment == null) {
|
||||||
|
emulationFragment = EmulationFragment.newInstance(game)
|
||||||
|
supportFragmentManager.beginTransaction()
|
||||||
|
.add(R.id.frame_emulation_fragment, emulationFragment!!)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
title = game.title
|
||||||
|
|
||||||
|
nfcReader = NfcReader(this)
|
||||||
|
nfcReader.initialize()
|
||||||
|
|
||||||
|
inputHandler = InputHandler()
|
||||||
|
inputHandler.initialize()
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
WindowInfoTracker.getOrCreate(this@EmulationActivity)
|
||||||
|
.windowLayoutInfo(this@EmulationActivity)
|
||||||
|
.collect { emulationFragment?.updateCurrentLayout(this@EmulationActivity, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a foreground service to prevent the app from getting killed in the background
|
||||||
|
val startIntent = Intent(this, ForegroundService::class.java)
|
||||||
|
startForegroundService(startIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_ENTER) {
|
||||||
|
// Special case, we do not support multiline input, dismiss the keyboard.
|
||||||
|
val overlayView: View =
|
||||||
|
this.findViewById(R.id.surface_input_overlay)
|
||||||
|
val im =
|
||||||
|
overlayView.context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
im.hideSoftInputFromWindow(overlayView.windowToken, 0)
|
||||||
|
} else {
|
||||||
|
val textChar = event.unicodeChar
|
||||||
|
if (textChar == 0) {
|
||||||
|
// No text, button input.
|
||||||
|
NativeLibrary.submitInlineKeyboardInput(keyCode)
|
||||||
|
} else {
|
||||||
|
// Text submitted.
|
||||||
|
NativeLibrary.submitInlineKeyboardText(textChar.toChar().toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
nfcReader.startScanning()
|
||||||
|
startMotionSensorListener()
|
||||||
|
|
||||||
|
NativeLibrary.notifyOrientationChange(
|
||||||
|
EmulationMenuSettings.landscapeScreenLayout,
|
||||||
|
getAdjustedRotation()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
nfcReader.stopScanning()
|
||||||
|
stopMotionSensorListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
nfcReader.onNewIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
outState.putParcelable(EXTRA_SELECTED_GAME, game)
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||||
|
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||||
|
) {
|
||||||
|
return super.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputHandler.dispatchKeyEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||||
|
if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK &&
|
||||||
|
event.source and InputDevice.SOURCE_GAMEPAD != InputDevice.SOURCE_GAMEPAD
|
||||||
|
) {
|
||||||
|
return super.dispatchGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't attempt to do anything if we are disconnecting a device.
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputHandler.dispatchGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
|
val rotation = this.display?.rotation
|
||||||
|
if (rotation == Surface.ROTATION_90) {
|
||||||
|
flipMotionOrientation = true
|
||||||
|
}
|
||||||
|
if (rotation == Surface.ROTATION_270) {
|
||||||
|
flipMotionOrientation = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.sensor.type == Sensor.TYPE_ACCELEROMETER) {
|
||||||
|
if (flipMotionOrientation) {
|
||||||
|
accel[0] = event.values[1] / SensorManager.GRAVITY_EARTH
|
||||||
|
accel[1] = -event.values[0] / SensorManager.GRAVITY_EARTH
|
||||||
|
} else {
|
||||||
|
accel[0] = -event.values[1] / SensorManager.GRAVITY_EARTH
|
||||||
|
accel[1] = event.values[0] / SensorManager.GRAVITY_EARTH
|
||||||
|
}
|
||||||
|
accel[2] = -event.values[2] / SensorManager.GRAVITY_EARTH
|
||||||
|
}
|
||||||
|
if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
|
||||||
|
// Investigate why sensor value is off by 6x
|
||||||
|
if (flipMotionOrientation) {
|
||||||
|
gyro[0] = -event.values[1] / 6.0f
|
||||||
|
gyro[1] = event.values[0] / 6.0f
|
||||||
|
} else {
|
||||||
|
gyro[0] = event.values[1] / 6.0f
|
||||||
|
gyro[1] = -event.values[0] / 6.0f
|
||||||
|
}
|
||||||
|
gyro[2] = event.values[2] / 6.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update state on accelerometer data
|
||||||
|
if (event.sensor.type != Sensor.TYPE_ACCELEROMETER) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val deltaTimestamp = (event.timestamp - motionTimestamp) / 1000
|
||||||
|
motionTimestamp = event.timestamp
|
||||||
|
NativeLibrary.onGamePadMotionEvent(
|
||||||
|
NativeLibrary.Player1Device,
|
||||||
|
deltaTimestamp,
|
||||||
|
gyro[0],
|
||||||
|
gyro[1],
|
||||||
|
gyro[2],
|
||||||
|
accel[0],
|
||||||
|
accel[1],
|
||||||
|
accel[2]
|
||||||
|
)
|
||||||
|
NativeLibrary.onGamePadMotionEvent(
|
||||||
|
NativeLibrary.ConsoleDevice,
|
||||||
|
deltaTimestamp,
|
||||||
|
gyro[0],
|
||||||
|
gyro[1],
|
||||||
|
gyro[2],
|
||||||
|
accel[0],
|
||||||
|
accel[1],
|
||||||
|
accel[2]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
|
||||||
|
|
||||||
|
private fun getAdjustedRotation():Int {
|
||||||
|
val rotation = getSystemService<DisplayManager>()!!.getDisplay(Display.DEFAULT_DISPLAY).rotation
|
||||||
|
val config: Configuration = resources.configuration
|
||||||
|
|
||||||
|
if ((config.screenLayout and Configuration.SCREENLAYOUT_LONG_YES) != 0 ||
|
||||||
|
(config.screenLayout and Configuration.SCREENLAYOUT_LONG_NO) == 0) {
|
||||||
|
return rotation
|
||||||
|
}
|
||||||
|
when (rotation) {
|
||||||
|
Surface.ROTATION_0 -> return Surface.ROTATION_90
|
||||||
|
Surface.ROTATION_90 -> return Surface.ROTATION_0
|
||||||
|
Surface.ROTATION_180 -> return Surface.ROTATION_270
|
||||||
|
Surface.ROTATION_270 -> return Surface.ROTATION_180
|
||||||
|
}
|
||||||
|
return rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreState(savedInstanceState: Bundle) {
|
||||||
|
game = savedInstanceState.parcelable(EXTRA_SELECTED_GAME)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableFullscreenImmersive() {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
|
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
|
||||||
|
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
controller.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startMotionSensorListener() {
|
||||||
|
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||||
|
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||||
|
sensorManager.registerListener(this, gyroSensor, SensorManager.SENSOR_DELAY_GAME)
|
||||||
|
sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_GAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopMotionSensorListener() {
|
||||||
|
val sensorManager = this.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||||
|
val gyroSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||||
|
val accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||||
|
|
||||||
|
sensorManager.unregisterListener(this, gyroSensor)
|
||||||
|
sensorManager.unregisterListener(this, accelSensor)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_SELECTED_GAME = "SelectedGame"
|
||||||
|
|
||||||
|
fun launch(activity: AppCompatActivity, game: Game) {
|
||||||
|
val launcher = Intent(activity, EmulationActivity::class.java)
|
||||||
|
launcher.putExtra(EXTRA_SELECTED_GAME, game)
|
||||||
|
activity.startActivity(launcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopForegroundService(activity: Activity) {
|
||||||
|
val startIntent = Intent(activity, ForegroundService::class.java)
|
||||||
|
startIntent.action = ForegroundService.ACTION_STOP
|
||||||
|
activity.startForegroundService(startIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun areCoordinatesOutside(view: View?, x: Float, y: Float): Boolean {
|
||||||
|
if (view == null) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val viewBounds = Rect()
|
||||||
|
view.getGlobalVisibleRect(viewBounds)
|
||||||
|
return !viewBounds.contains(x.roundToInt(), y.roundToInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,134 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.recyclerview.widget.AsyncDifferConfig
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import coil.load
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.databinding.CardGameBinding
|
||||||
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import org.yuzu.yuzu_emu.adapters.GameAdapter.GameViewHolder
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
|
||||||
|
class GameAdapter(private val activity: AppCompatActivity) :
|
||||||
|
ListAdapter<Game, GameViewHolder>(AsyncDifferConfig.Builder(DiffCallback()).build()),
|
||||||
|
View.OnClickListener {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
|
||||||
|
// Create a new view.
|
||||||
|
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
binding.cardGame.setOnClickListener(this)
|
||||||
|
|
||||||
|
// Use that view to create a ViewHolder.
|
||||||
|
return GameViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
|
||||||
|
holder.bind(currentList[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = currentList.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches the game that was clicked on.
|
||||||
|
*
|
||||||
|
* @param view The card representing the game the user wants to play.
|
||||||
|
*/
|
||||||
|
override fun onClick(view: View) {
|
||||||
|
val holder = view.tag as GameViewHolder
|
||||||
|
|
||||||
|
val gameExists = DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(holder.game.path))?.exists() == true
|
||||||
|
if (!gameExists) {
|
||||||
|
Toast.makeText(
|
||||||
|
YuzuApplication.appContext,
|
||||||
|
R.string.loader_error_file_not_found,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
ViewModelProvider(activity)[GamesViewModel::class.java].reloadGames(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
preferences.edit()
|
||||||
|
.putLong(
|
||||||
|
holder.game.keyLastPlayedTime,
|
||||||
|
System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
EmulationActivity.launch(activity, holder.game)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class GameViewHolder(val binding: CardGameBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
lateinit var game: Game
|
||||||
|
|
||||||
|
init {
|
||||||
|
binding.cardGame.tag = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(game: Game) {
|
||||||
|
this.game = game
|
||||||
|
|
||||||
|
binding.imageGameScreen.scaleType = ImageView.ScaleType.CENTER_CROP
|
||||||
|
activity.lifecycleScope.launch {
|
||||||
|
val bitmap = decodeGameIcon(game.path)
|
||||||
|
binding.imageGameScreen.load(bitmap) {
|
||||||
|
error(R.drawable.default_icon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.textGameTitle.text = game.title.replace("[\\t\\n\\r]+".toRegex(), " ")
|
||||||
|
|
||||||
|
binding.textGameTitle.postDelayed(
|
||||||
|
{
|
||||||
|
binding.textGameTitle.ellipsize = TextUtils.TruncateAt.MARQUEE
|
||||||
|
binding.textGameTitle.isSelected = true
|
||||||
|
},
|
||||||
|
3000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DiffCallback : DiffUtil.ItemCallback<Game>() {
|
||||||
|
override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
|
||||||
|
return oldItem.gameId == newItem.gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
|
||||||
|
return oldItem == newItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeGameIcon(uri: String): Bitmap? {
|
||||||
|
val data = NativeLibrary.getIcon(uri)
|
||||||
|
return BitmapFactory.decodeByteArray(
|
||||||
|
data,
|
||||||
|
0,
|
||||||
|
data.size,
|
||||||
|
BitmapFactory.Options()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.CardHomeOptionBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeSetting
|
||||||
|
|
||||||
|
class HomeSettingAdapter(private val activity: AppCompatActivity, var options: List<HomeSetting>) :
|
||||||
|
RecyclerView.Adapter<HomeSettingAdapter.HomeOptionViewHolder>(),
|
||||||
|
View.OnClickListener {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HomeOptionViewHolder {
|
||||||
|
val binding =
|
||||||
|
CardHomeOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
binding.root.setOnClickListener(this)
|
||||||
|
return HomeOptionViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return options.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: HomeOptionViewHolder, position: Int) {
|
||||||
|
holder.bind(options[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(view: View) {
|
||||||
|
val holder = view.tag as HomeOptionViewHolder
|
||||||
|
holder.option.onClick.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class HomeOptionViewHolder(val binding: CardHomeOptionBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
lateinit var option: HomeSetting
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.tag = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(option: HomeSetting) {
|
||||||
|
this.option = option
|
||||||
|
binding.optionTitle.text = activity.resources.getString(option.titleId)
|
||||||
|
binding.optionDescription.text = activity.resources.getString(option.descriptionId)
|
||||||
|
binding.optionIcon.setImageDrawable(
|
||||||
|
ResourcesCompat.getDrawable(
|
||||||
|
activity.resources,
|
||||||
|
option.iconId,
|
||||||
|
activity.theme
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
when (option.titleId) {
|
||||||
|
R.string.get_early_access -> binding.optionLayout.background =
|
||||||
|
ContextCompat.getDrawable(
|
||||||
|
binding.optionCard.context,
|
||||||
|
R.drawable.premium_background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.fragments.LicenseBottomSheetDialogFragment
|
||||||
|
import org.yuzu.yuzu_emu.model.License
|
||||||
|
|
||||||
|
class LicenseAdapter(private val activity: AppCompatActivity, var licenses: List<License>) :
|
||||||
|
RecyclerView.Adapter<LicenseAdapter.LicenseViewHolder>(),
|
||||||
|
View.OnClickListener {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LicenseViewHolder {
|
||||||
|
val binding =
|
||||||
|
ListItemSettingBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
binding.root.setOnClickListener(this)
|
||||||
|
return LicenseViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = licenses.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: LicenseViewHolder, position: Int) {
|
||||||
|
holder.bind(licenses[position])
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(view: View) {
|
||||||
|
val license = (view.tag as LicenseViewHolder).license
|
||||||
|
LicenseBottomSheetDialogFragment.newInstance(license)
|
||||||
|
.show(activity.supportFragmentManager, LicenseBottomSheetDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class LicenseViewHolder(val binding: ListItemSettingBinding) : ViewHolder(binding.root) {
|
||||||
|
lateinit var license: License
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.tag = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(license: License) {
|
||||||
|
this.license = license
|
||||||
|
|
||||||
|
val context = YuzuApplication.appContext
|
||||||
|
binding.textSettingName.text = context.getString(license.titleId)
|
||||||
|
binding.textSettingDescription.text = context.getString(license.descriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.adapters
|
||||||
|
|
||||||
|
import android.text.Html
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import org.yuzu.yuzu_emu.databinding.PageSetupBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.SetupPage
|
||||||
|
|
||||||
|
class SetupAdapter(val activity: AppCompatActivity, val pages: List<SetupPage>) :
|
||||||
|
RecyclerView.Adapter<SetupAdapter.SetupPageViewHolder>() {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SetupPageViewHolder {
|
||||||
|
val binding = PageSetupBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return SetupPageViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = pages.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: SetupPageViewHolder, position: Int) =
|
||||||
|
holder.bind(pages[position])
|
||||||
|
|
||||||
|
inner class SetupPageViewHolder(val binding: PageSetupBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
lateinit var page: SetupPage
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.tag = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind(page: SetupPage) {
|
||||||
|
this.page = page
|
||||||
|
binding.icon.setImageDrawable(
|
||||||
|
ResourcesCompat.getDrawable(
|
||||||
|
activity.resources,
|
||||||
|
page.iconId,
|
||||||
|
activity.theme
|
||||||
|
)
|
||||||
|
)
|
||||||
|
binding.textTitle.text = activity.resources.getString(page.titleId)
|
||||||
|
binding.textDescription.text =
|
||||||
|
Html.fromHtml(activity.resources.getString(page.descriptionId), 0)
|
||||||
|
|
||||||
|
binding.buttonAction.apply {
|
||||||
|
text = activity.resources.getString(page.buttonTextId)
|
||||||
|
if (page.buttonIconId != 0) {
|
||||||
|
icon = ResourcesCompat.getDrawable(
|
||||||
|
activity.resources,
|
||||||
|
page.buttonIconId,
|
||||||
|
activity.theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconGravity =
|
||||||
|
if (page.leftAlignedIcon) {
|
||||||
|
MaterialButton.ICON_GRAVITY_START
|
||||||
|
} else {
|
||||||
|
MaterialButton.ICON_GRAVITY_END
|
||||||
|
}
|
||||||
|
setOnClickListener {
|
||||||
|
page.buttonAction.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,121 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.applets.keyboard
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.applets.keyboard.ui.KeyboardDialogFragment
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
object SoftwareKeyboard {
|
||||||
|
lateinit var data: KeyboardData
|
||||||
|
val dataLock = Object()
|
||||||
|
|
||||||
|
private fun executeNormalImpl(config: KeyboardConfig) {
|
||||||
|
val emulationActivity = NativeLibrary.sEmulationActivity.get()
|
||||||
|
data = KeyboardData(SwkbdResult.Cancel.ordinal, "")
|
||||||
|
val fragment = KeyboardDialogFragment.newInstance(config)
|
||||||
|
fragment.show(emulationActivity!!.supportFragmentManager, KeyboardDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeInlineImpl(config: KeyboardConfig) {
|
||||||
|
val emulationActivity = NativeLibrary.sEmulationActivity.get()
|
||||||
|
|
||||||
|
val overlayView = emulationActivity!!.findViewById<View>(R.id.surface_input_overlay)
|
||||||
|
val im =
|
||||||
|
overlayView.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
im.showSoftInput(overlayView, InputMethodManager.SHOW_FORCED)
|
||||||
|
|
||||||
|
// There isn't a good way to know that the IMM is dismissed, so poll every 500ms to submit inline keyboard result.
|
||||||
|
val handler = Handler(Looper.myLooper()!!)
|
||||||
|
val delayMs = 500
|
||||||
|
handler.postDelayed(object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
val insets = ViewCompat.getRootWindowInsets(overlayView)
|
||||||
|
val isKeyboardVisible = insets!!.isVisible(WindowInsets.Type.ime())
|
||||||
|
if (isKeyboardVisible) {
|
||||||
|
handler.postDelayed(this, delayMs.toLong())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No longer visible, submit the result.
|
||||||
|
NativeLibrary.submitInlineKeyboardInput(KeyEvent.KEYCODE_ENTER)
|
||||||
|
}
|
||||||
|
}, delayMs.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun executeNormal(config: KeyboardConfig): KeyboardData {
|
||||||
|
NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeNormalImpl(config) }
|
||||||
|
synchronized(dataLock) {
|
||||||
|
dataLock.wait()
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun executeInline(config: KeyboardConfig) {
|
||||||
|
NativeLibrary.sEmulationActivity.get()!!.runOnUiThread { executeInlineImpl(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corresponds to Service::AM::Applets::SwkbdType
|
||||||
|
enum class SwkbdType {
|
||||||
|
Normal,
|
||||||
|
NumberPad,
|
||||||
|
Qwerty,
|
||||||
|
Unknown3,
|
||||||
|
Latin,
|
||||||
|
SimplifiedChinese,
|
||||||
|
TraditionalChinese,
|
||||||
|
Korean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corresponds to Service::AM::Applets::SwkbdPasswordMode
|
||||||
|
enum class SwkbdPasswordMode {
|
||||||
|
Disabled,
|
||||||
|
Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corresponds to Service::AM::Applets::SwkbdResult
|
||||||
|
enum class SwkbdResult {
|
||||||
|
Ok,
|
||||||
|
Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
data class KeyboardConfig(
|
||||||
|
var ok_text: String? = null,
|
||||||
|
var header_text: String? = null,
|
||||||
|
var sub_text: String? = null,
|
||||||
|
var guide_text: String? = null,
|
||||||
|
var initial_text: String? = null,
|
||||||
|
var left_optional_symbol_key: Short = 0,
|
||||||
|
var right_optional_symbol_key: Short = 0,
|
||||||
|
var max_text_length: Int = 0,
|
||||||
|
var min_text_length: Int = 0,
|
||||||
|
var initial_cursor_position: Int = 0,
|
||||||
|
var type: Int = 0,
|
||||||
|
var password_mode: Int = 0,
|
||||||
|
var text_draw_type: Int = 0,
|
||||||
|
var key_disable_flags: Int = 0,
|
||||||
|
var use_blur_background: Boolean = false,
|
||||||
|
var enable_backspace_button: Boolean = false,
|
||||||
|
var enable_return_button: Boolean = false,
|
||||||
|
var disable_cancel_button: Boolean = false
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
|
// Corresponds to Frontend::KeyboardData
|
||||||
|
@Keep
|
||||||
|
data class KeyboardData(var result: Int, var text: String)
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: Copyright 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.applets.keyboard.ui
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.InputFilter
|
||||||
|
import android.text.InputType
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard
|
||||||
|
import org.yuzu.yuzu_emu.applets.keyboard.SoftwareKeyboard.KeyboardConfig
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogEditTextBinding
|
||||||
|
import org.yuzu.yuzu_emu.utils.SerializableHelper.serializable
|
||||||
|
|
||||||
|
class KeyboardDialogFragment : DialogFragment() {
|
||||||
|
private lateinit var binding: DialogEditTextBinding
|
||||||
|
private lateinit var config: KeyboardConfig
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
binding = DialogEditTextBinding.inflate(layoutInflater)
|
||||||
|
config = requireArguments().serializable(CONFIG)!!
|
||||||
|
|
||||||
|
// Set up the input
|
||||||
|
binding.editText.hint = config.initial_text
|
||||||
|
binding.editText.isSingleLine = !config.enable_return_button
|
||||||
|
binding.editText.filters =
|
||||||
|
arrayOf<InputFilter>(InputFilter.LengthFilter(config.max_text_length))
|
||||||
|
|
||||||
|
// Handle input type
|
||||||
|
var inputType: Int
|
||||||
|
when (config.type) {
|
||||||
|
SoftwareKeyboard.SwkbdType.Normal.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.Qwerty.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.Unknown3.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.Latin.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.SimplifiedChinese.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.TraditionalChinese.ordinal,
|
||||||
|
SoftwareKeyboard.SwkbdType.Korean.ordinal -> {
|
||||||
|
inputType = InputType.TYPE_CLASS_TEXT
|
||||||
|
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
|
||||||
|
inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SoftwareKeyboard.SwkbdType.NumberPad.ordinal -> {
|
||||||
|
inputType = InputType.TYPE_CLASS_NUMBER
|
||||||
|
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
|
||||||
|
inputType = inputType or InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
inputType = InputType.TYPE_CLASS_TEXT
|
||||||
|
if (config.password_mode == SoftwareKeyboard.SwkbdPasswordMode.Enabled.ordinal) {
|
||||||
|
inputType = inputType or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.editText.inputType = inputType
|
||||||
|
|
||||||
|
val headerText =
|
||||||
|
config.header_text!!.ifEmpty { resources.getString(R.string.software_keyboard) }
|
||||||
|
val okText =
|
||||||
|
config.ok_text!!.ifEmpty { resources.getString(R.string.submit) }
|
||||||
|
|
||||||
|
return MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(headerText)
|
||||||
|
.setView(binding.root)
|
||||||
|
.setPositiveButton(okText) { _, _ ->
|
||||||
|
SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Ok.ordinal
|
||||||
|
SoftwareKeyboard.data.text = binding.editText.text.toString()
|
||||||
|
}
|
||||||
|
.setNegativeButton(resources.getString(android.R.string.cancel)) { _, _ ->
|
||||||
|
SoftwareKeyboard.data.result = SoftwareKeyboard.SwkbdResult.Cancel.ordinal
|
||||||
|
}
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDismiss(dialog: DialogInterface) {
|
||||||
|
super.onDismiss(dialog)
|
||||||
|
synchronized(SoftwareKeyboard.dataLock) {
|
||||||
|
SoftwareKeyboard.dataLock.notifyAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "KeyboardDialogFragment"
|
||||||
|
const val CONFIG = "keyboard_config"
|
||||||
|
|
||||||
|
fun newInstance(config: KeyboardConfig?): KeyboardDialogFragment {
|
||||||
|
val frag = KeyboardDialogFragment()
|
||||||
|
val args = Bundle()
|
||||||
|
args.putSerializable(CONFIG, config)
|
||||||
|
frag.arguments = args
|
||||||
|
return frag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.disk_shader_cache
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.disk_shader_cache.ui.ShaderProgressDialogFragment
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
object DiskShaderCacheProgress {
|
||||||
|
val finishLock = Object()
|
||||||
|
private lateinit var fragment: ShaderProgressDialogFragment
|
||||||
|
|
||||||
|
private fun prepareDialog() {
|
||||||
|
val emulationActivity = NativeLibrary.sEmulationActivity.get()!!
|
||||||
|
emulationActivity.runOnUiThread {
|
||||||
|
fragment = ShaderProgressDialogFragment.newInstance(
|
||||||
|
emulationActivity.getString(R.string.loading),
|
||||||
|
emulationActivity.getString(R.string.preparing_shaders)
|
||||||
|
)
|
||||||
|
fragment.show(emulationActivity.supportFragmentManager, ShaderProgressDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
synchronized(finishLock) { finishLock.wait() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun loadProgress(stage: Int, progress: Int, max: Int) {
|
||||||
|
val emulationActivity = NativeLibrary.sEmulationActivity.get()
|
||||||
|
?: error("[DiskShaderCacheProgress] EmulationActivity not present")
|
||||||
|
|
||||||
|
when (LoadCallbackStage.values()[stage]) {
|
||||||
|
LoadCallbackStage.Prepare -> prepareDialog()
|
||||||
|
LoadCallbackStage.Build -> fragment.onUpdateProgress(
|
||||||
|
emulationActivity.getString(R.string.building_shaders),
|
||||||
|
progress,
|
||||||
|
max
|
||||||
|
)
|
||||||
|
LoadCallbackStage.Complete -> fragment.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equivalent to VideoCore::LoadCallbackStage
|
||||||
|
enum class LoadCallbackStage {
|
||||||
|
Prepare, Build, Complete
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.disk_shader_cache
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
|
class ShaderProgressViewModel : ViewModel() {
|
||||||
|
private val _progress = MutableLiveData(0)
|
||||||
|
val progress: LiveData<Int> get() = _progress
|
||||||
|
|
||||||
|
private val _max = MutableLiveData(0)
|
||||||
|
val max: LiveData<Int> get() = _max
|
||||||
|
|
||||||
|
private val _message = MutableLiveData("")
|
||||||
|
val message: LiveData<String> get() = _message
|
||||||
|
|
||||||
|
fun setProgress(progress: Int) {
|
||||||
|
_progress.postValue(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMax(max: Int) {
|
||||||
|
_max.postValue(max)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMessage(msg: String) {
|
||||||
|
_message.postValue(msg)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.disk_shader_cache.ui
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
|
||||||
|
import org.yuzu.yuzu_emu.disk_shader_cache.DiskShaderCacheProgress
|
||||||
|
import org.yuzu.yuzu_emu.disk_shader_cache.ShaderProgressViewModel
|
||||||
|
|
||||||
|
class ShaderProgressDialogFragment : DialogFragment() {
|
||||||
|
private var _binding: DialogProgressBarBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var alertDialog: AlertDialog
|
||||||
|
|
||||||
|
private lateinit var shaderProgressViewModel: ShaderProgressViewModel
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
_binding = DialogProgressBarBinding.inflate(layoutInflater)
|
||||||
|
shaderProgressViewModel =
|
||||||
|
ViewModelProvider(requireActivity())[ShaderProgressViewModel::class.java]
|
||||||
|
|
||||||
|
val title = requireArguments().getString(TITLE)
|
||||||
|
val message = requireArguments().getString(MESSAGE)
|
||||||
|
|
||||||
|
isCancelable = false
|
||||||
|
alertDialog = MaterialAlertDialogBuilder(requireActivity())
|
||||||
|
.setView(binding.root)
|
||||||
|
.setTitle(title)
|
||||||
|
.setMessage(message)
|
||||||
|
.create()
|
||||||
|
return alertDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
shaderProgressViewModel.progress.observe(viewLifecycleOwner) { progress ->
|
||||||
|
binding.progressBar.progress = progress
|
||||||
|
setUpdateText()
|
||||||
|
}
|
||||||
|
shaderProgressViewModel.max.observe(viewLifecycleOwner) { max ->
|
||||||
|
binding.progressBar.max = max
|
||||||
|
setUpdateText()
|
||||||
|
}
|
||||||
|
shaderProgressViewModel.message.observe(viewLifecycleOwner) { msg ->
|
||||||
|
alertDialog.setMessage(msg)
|
||||||
|
}
|
||||||
|
synchronized(DiskShaderCacheProgress.finishLock) { DiskShaderCacheProgress.finishLock.notifyAll() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUpdateProgress(msg: String, progress: Int, max: Int) {
|
||||||
|
shaderProgressViewModel.setProgress(progress)
|
||||||
|
shaderProgressViewModel.setMax(max)
|
||||||
|
shaderProgressViewModel.setMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUpdateText() {
|
||||||
|
binding.progressText.text = String.format(
|
||||||
|
"%d/%d",
|
||||||
|
shaderProgressViewModel.progress.value,
|
||||||
|
shaderProgressViewModel.max.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ProgressDialogFragment"
|
||||||
|
const val TITLE = "title"
|
||||||
|
const val MESSAGE = "message"
|
||||||
|
|
||||||
|
fun newInstance(title: String, message: String): ShaderProgressDialogFragment {
|
||||||
|
val frag = ShaderProgressDialogFragment()
|
||||||
|
val args = Bundle()
|
||||||
|
args.putString(TITLE, title)
|
||||||
|
args.putString(MESSAGE, message)
|
||||||
|
frag.arguments = args
|
||||||
|
return frag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,302 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
// Copyright © 2023 Skyline Team and Contributors (https://github.com/skyline-emu/)
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.DocumentsProvider
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import org.yuzu.yuzu_emu.BuildConfig
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.getPublicFilesDir
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
|
class DocumentProvider : DocumentsProvider() {
|
||||||
|
private val baseDirectory: File
|
||||||
|
get() = File(YuzuApplication.application.getPublicFilesDir().canonicalPath)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
||||||
|
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||||
|
DocumentsContract.Root.COLUMN_MIME_TYPES,
|
||||||
|
DocumentsContract.Root.COLUMN_FLAGS,
|
||||||
|
DocumentsContract.Root.COLUMN_ICON,
|
||||||
|
DocumentsContract.Root.COLUMN_TITLE,
|
||||||
|
DocumentsContract.Root.COLUMN_SUMMARY,
|
||||||
|
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Root.COLUMN_AVAILABLE_BYTES
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DEFAULT_DOCUMENT_PROJECTION: Array<String> = arrayOf(
|
||||||
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
|
||||||
|
DocumentsContract.Document.COLUMN_FLAGS,
|
||||||
|
DocumentsContract.Document.COLUMN_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
const val AUTHORITY : String = BuildConfig.APPLICATION_ID + ".user"
|
||||||
|
const val ROOT_ID: String = "root"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The [File] that corresponds to the document ID supplied by [getDocumentId]
|
||||||
|
*/
|
||||||
|
private fun getFile(documentId: String): File {
|
||||||
|
if (documentId.startsWith(ROOT_ID)) {
|
||||||
|
val file = baseDirectory.resolve(documentId.drop(ROOT_ID.length + 1))
|
||||||
|
if (!file.exists()) throw FileNotFoundException("${file.absolutePath} ($documentId) not found")
|
||||||
|
return file
|
||||||
|
} else {
|
||||||
|
throw FileNotFoundException("'$documentId' is not in any known root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return A unique ID for the provided [File]
|
||||||
|
*/
|
||||||
|
private fun getDocumentId(file: File): String {
|
||||||
|
return "$ROOT_ID/${file.toRelativeString(baseDirectory)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryRoots(projection: Array<out String>?): Cursor {
|
||||||
|
val cursor = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION)
|
||||||
|
|
||||||
|
cursor.newRow().apply {
|
||||||
|
add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID)
|
||||||
|
add(DocumentsContract.Root.COLUMN_SUMMARY, null)
|
||||||
|
add(
|
||||||
|
DocumentsContract.Root.COLUMN_FLAGS,
|
||||||
|
DocumentsContract.Root.FLAG_SUPPORTS_CREATE or DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD
|
||||||
|
)
|
||||||
|
add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name))
|
||||||
|
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, getDocumentId(baseDirectory))
|
||||||
|
add(DocumentsContract.Root.COLUMN_MIME_TYPES, "*/*")
|
||||||
|
add(DocumentsContract.Root.COLUMN_AVAILABLE_BYTES, baseDirectory.freeSpace)
|
||||||
|
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryDocument(documentId: String?, projection: Array<out String>?): Cursor {
|
||||||
|
val cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||||
|
return includeFile(cursor, documentId, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isChildDocument(parentDocumentId: String?, documentId: String?): Boolean {
|
||||||
|
return documentId?.startsWith(parentDocumentId!!) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return A new [File] with a unique name based off the supplied [name], not conflicting with any existing file
|
||||||
|
*/
|
||||||
|
private fun File.resolveWithoutConflict(name: String): File {
|
||||||
|
var file = resolve(name)
|
||||||
|
if (file.exists()) {
|
||||||
|
var noConflictId =
|
||||||
|
1 // Makes sure two files don't have the same name by adding a number to the end
|
||||||
|
val extension = name.substringAfterLast('.')
|
||||||
|
val baseName = name.substringBeforeLast('.')
|
||||||
|
while (file.exists())
|
||||||
|
file = resolve("$baseName (${noConflictId++}).$extension")
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createDocument(
|
||||||
|
parentDocumentId: String?,
|
||||||
|
mimeType: String?,
|
||||||
|
displayName: String
|
||||||
|
): String {
|
||||||
|
val parentFile = getFile(parentDocumentId!!)
|
||||||
|
val newFile = parentFile.resolveWithoutConflict(displayName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (DocumentsContract.Document.MIME_TYPE_DIR == mimeType) {
|
||||||
|
if (!newFile.mkdir())
|
||||||
|
throw IOException("Failed to create directory")
|
||||||
|
} else {
|
||||||
|
if (!newFile.createNewFile())
|
||||||
|
throw IOException("Failed to create file")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw FileNotFoundException("Couldn't create document '${newFile.path}': ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDocumentId(newFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteDocument(documentId: String?) {
|
||||||
|
val file = getFile(documentId!!)
|
||||||
|
if (!file.delete())
|
||||||
|
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun removeDocument(documentId: String, parentDocumentId: String?) {
|
||||||
|
val parent = getFile(parentDocumentId!!)
|
||||||
|
val file = getFile(documentId)
|
||||||
|
|
||||||
|
if (parent == file || file.parentFile == null || file.parentFile!! == parent) {
|
||||||
|
if (!file.delete())
|
||||||
|
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
|
||||||
|
} else {
|
||||||
|
throw FileNotFoundException("Couldn't delete document with ID '$documentId'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun renameDocument(documentId: String?, displayName: String?): String {
|
||||||
|
if (displayName == null)
|
||||||
|
throw FileNotFoundException("Couldn't rename document '$documentId' as the new name is null")
|
||||||
|
|
||||||
|
val sourceFile = getFile(documentId!!)
|
||||||
|
val sourceParentFile = sourceFile.parentFile
|
||||||
|
?: throw FileNotFoundException("Couldn't rename document '$documentId' as it has no parent")
|
||||||
|
val destFile = sourceParentFile.resolve(displayName)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!sourceFile.renameTo(destFile))
|
||||||
|
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}'")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw FileNotFoundException("Couldn't rename document from '${sourceFile.name}' to '${destFile.name}': ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDocumentId(destFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyDocument(
|
||||||
|
sourceDocumentId: String, sourceParentDocumentId: String,
|
||||||
|
targetParentDocumentId: String?
|
||||||
|
): String {
|
||||||
|
if (!isChildDocument(sourceParentDocumentId, sourceDocumentId))
|
||||||
|
throw FileNotFoundException("Couldn't copy document '$sourceDocumentId' as its parent is not '$sourceParentDocumentId'")
|
||||||
|
|
||||||
|
return copyDocument(sourceDocumentId, targetParentDocumentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun copyDocument(sourceDocumentId: String, targetParentDocumentId: String?): String {
|
||||||
|
val parent = getFile(targetParentDocumentId!!)
|
||||||
|
val oldFile = getFile(sourceDocumentId)
|
||||||
|
val newFile = parent.resolveWithoutConflict(oldFile.name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(newFile.createNewFile() && newFile.setWritable(true) && newFile.setReadable(true)))
|
||||||
|
throw IOException("Couldn't create new file")
|
||||||
|
|
||||||
|
FileInputStream(oldFile).use { inStream ->
|
||||||
|
FileOutputStream(newFile).use { outStream ->
|
||||||
|
inStream.copyTo(outStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw FileNotFoundException("Couldn't copy document '$sourceDocumentId': ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDocumentId(newFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun moveDocument(
|
||||||
|
sourceDocumentId: String, sourceParentDocumentId: String?,
|
||||||
|
targetParentDocumentId: String?
|
||||||
|
): String {
|
||||||
|
try {
|
||||||
|
val newDocumentId = copyDocument(
|
||||||
|
sourceDocumentId, sourceParentDocumentId!!,
|
||||||
|
targetParentDocumentId
|
||||||
|
)
|
||||||
|
removeDocument(sourceDocumentId, sourceParentDocumentId)
|
||||||
|
return newDocumentId
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
throw FileNotFoundException("Couldn't move document '$sourceDocumentId'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun includeFile(cursor: MatrixCursor, documentId: String?, file: File?): MatrixCursor {
|
||||||
|
val localDocumentId = documentId ?: file?.let { getDocumentId(it) }
|
||||||
|
val localFile = file ?: getFile(documentId!!)
|
||||||
|
|
||||||
|
var flags = 0
|
||||||
|
if (localFile.isDirectory && localFile.canWrite()) {
|
||||||
|
flags = DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE
|
||||||
|
} else if (localFile.canWrite()) {
|
||||||
|
flags = DocumentsContract.Document.FLAG_SUPPORTS_WRITE
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
|
||||||
|
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_REMOVE
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_MOVE
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_COPY
|
||||||
|
flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.newRow().apply {
|
||||||
|
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, localDocumentId)
|
||||||
|
add(
|
||||||
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
|
if (localFile == baseDirectory) context!!.getString(R.string.app_name) else localFile.name
|
||||||
|
)
|
||||||
|
add(DocumentsContract.Document.COLUMN_SIZE, localFile.length())
|
||||||
|
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getTypeForFile(localFile))
|
||||||
|
add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, localFile.lastModified())
|
||||||
|
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
|
||||||
|
if (localFile == baseDirectory)
|
||||||
|
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_yuzu)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTypeForFile(file: File): Any {
|
||||||
|
return if (file.isDirectory)
|
||||||
|
DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
else
|
||||||
|
getTypeForName(file.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTypeForName(name: String): Any {
|
||||||
|
val lastDot = name.lastIndexOf('.')
|
||||||
|
if (lastDot >= 0) {
|
||||||
|
val extension = name.substring(lastDot + 1)
|
||||||
|
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||||
|
if (mime != null)
|
||||||
|
return mime
|
||||||
|
}
|
||||||
|
return "application/octect-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun queryChildDocuments(
|
||||||
|
parentDocumentId: String?,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor {
|
||||||
|
var cursor = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION)
|
||||||
|
|
||||||
|
val parent = getFile(parentDocumentId!!)
|
||||||
|
for (file in parent.listFiles()!!)
|
||||||
|
cursor = includeFile(cursor, null, file)
|
||||||
|
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openDocument(
|
||||||
|
documentId: String?,
|
||||||
|
mode: String?,
|
||||||
|
signal: CancellationSignal?
|
||||||
|
): ParcelFileDescriptor {
|
||||||
|
val file = documentId?.let { getFile(it) }
|
||||||
|
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||||
|
return ParcelFileDescriptor.open(file, accessMode)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
interface AbstractBooleanSetting : AbstractSetting {
|
||||||
|
var boolean: Boolean
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
interface AbstractFloatSetting : AbstractSetting {
|
||||||
|
var float: Float
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
interface AbstractIntSetting : AbstractSetting {
|
||||||
|
var int: Int
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
interface AbstractSetting {
|
||||||
|
val key: String?
|
||||||
|
val section: String?
|
||||||
|
val isRuntimeEditable: Boolean
|
||||||
|
val valueAsString: String
|
||||||
|
val defaultValue: Any
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
interface AbstractStringSetting : AbstractSetting {
|
||||||
|
var string: String
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
enum class BooleanSetting(
|
||||||
|
override val key: String,
|
||||||
|
override val section: String,
|
||||||
|
override val defaultValue: Boolean
|
||||||
|
) : AbstractBooleanSetting {
|
||||||
|
USE_CUSTOM_RTC("custom_rtc_enabled", Settings.SECTION_SYSTEM, false);
|
||||||
|
|
||||||
|
override var boolean: Boolean = defaultValue
|
||||||
|
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = boolean.toString()
|
||||||
|
|
||||||
|
override val isRuntimeEditable: Boolean
|
||||||
|
get() {
|
||||||
|
for (setting in NOT_RUNTIME_EDITABLE) {
|
||||||
|
if (setting == this) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val NOT_RUNTIME_EDITABLE = listOf(
|
||||||
|
USE_CUSTOM_RTC
|
||||||
|
)
|
||||||
|
|
||||||
|
fun from(key: String): BooleanSetting? =
|
||||||
|
BooleanSetting.values().firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
fun clear() = BooleanSetting.values().forEach { it.boolean = it.defaultValue }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
enum class FloatSetting(
|
||||||
|
override val key: String,
|
||||||
|
override val section: String,
|
||||||
|
override val defaultValue: Float
|
||||||
|
) : AbstractFloatSetting {
|
||||||
|
// No float settings currently exist
|
||||||
|
EMPTY_SETTING("", "", 0f);
|
||||||
|
|
||||||
|
override var float: Float = defaultValue
|
||||||
|
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = float.toString()
|
||||||
|
|
||||||
|
override val isRuntimeEditable: Boolean
|
||||||
|
get() {
|
||||||
|
for (setting in NOT_RUNTIME_EDITABLE) {
|
||||||
|
if (setting == this) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val NOT_RUNTIME_EDITABLE = emptyList<FloatSetting>()
|
||||||
|
|
||||||
|
fun from(key: String): FloatSetting? = FloatSetting.values().firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
fun clear() = FloatSetting.values().forEach { it.float = it.defaultValue }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,131 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
enum class IntSetting(
|
||||||
|
override val key: String,
|
||||||
|
override val section: String,
|
||||||
|
override val defaultValue: Int
|
||||||
|
) : AbstractIntSetting {
|
||||||
|
RENDERER_USE_SPEED_LIMIT(
|
||||||
|
"use_speed_limit",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
USE_DOCKED_MODE(
|
||||||
|
"use_docked_mode",
|
||||||
|
Settings.SECTION_SYSTEM,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_USE_DISK_SHADER_CACHE(
|
||||||
|
"use_disk_shader_cache",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
RENDERER_FORCE_MAX_CLOCK(
|
||||||
|
"force_max_clock",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
RENDERER_ASYNCHRONOUS_SHADERS(
|
||||||
|
"use_asynchronous_shaders",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_DEBUG(
|
||||||
|
"debug",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_SPEED_LIMIT(
|
||||||
|
"speed_limit",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
CPU_ACCURACY(
|
||||||
|
"cpu_accuracy",
|
||||||
|
Settings.SECTION_CPU,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
REGION_INDEX(
|
||||||
|
"region_index",
|
||||||
|
Settings.SECTION_SYSTEM,
|
||||||
|
-1
|
||||||
|
),
|
||||||
|
LANGUAGE_INDEX(
|
||||||
|
"language_index",
|
||||||
|
Settings.SECTION_SYSTEM,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
RENDERER_BACKEND(
|
||||||
|
"backend",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
RENDERER_ACCURACY(
|
||||||
|
"gpu_accuracy",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_RESOLUTION(
|
||||||
|
"resolution_setup",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
RENDERER_VSYNC(
|
||||||
|
"use_vsync",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_SCALING_FILTER(
|
||||||
|
"scaling_filter",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
RENDERER_ANTI_ALIASING(
|
||||||
|
"anti_aliasing",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
RENDERER_ASPECT_RATIO(
|
||||||
|
"aspect_ratio",
|
||||||
|
Settings.SECTION_RENDERER,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
AUDIO_VOLUME(
|
||||||
|
"volume",
|
||||||
|
Settings.SECTION_AUDIO,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
override var int: Int = defaultValue
|
||||||
|
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = int.toString()
|
||||||
|
|
||||||
|
override val isRuntimeEditable: Boolean
|
||||||
|
get() {
|
||||||
|
for (setting in NOT_RUNTIME_EDITABLE) {
|
||||||
|
if (setting == this) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val NOT_RUNTIME_EDITABLE = listOf(
|
||||||
|
RENDERER_USE_DISK_SHADER_CACHE,
|
||||||
|
RENDERER_ASYNCHRONOUS_SHADERS,
|
||||||
|
RENDERER_DEBUG,
|
||||||
|
RENDERER_BACKEND,
|
||||||
|
RENDERER_RESOLUTION,
|
||||||
|
RENDERER_VSYNC
|
||||||
|
)
|
||||||
|
|
||||||
|
fun from(key: String): IntSetting? = IntSetting.values().firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
fun clear() = IntSetting.values().forEach { it.int = it.defaultValue }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A semantically-related group of Settings objects. These Settings are
|
||||||
|
* internally stored as a HashMap.
|
||||||
|
*/
|
||||||
|
class SettingSection(val name: String) {
|
||||||
|
val settings = HashMap<String, AbstractSetting>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method; inserts a value directly into the backing HashMap.
|
||||||
|
*
|
||||||
|
* @param setting The Setting to be inserted.
|
||||||
|
*/
|
||||||
|
fun putSetting(setting: AbstractSetting) {
|
||||||
|
settings[setting.key!!] = setting
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method; gets a value directly from the backing HashMap.
|
||||||
|
*
|
||||||
|
* @param key Used to retrieve the Setting.
|
||||||
|
* @return A Setting object (you should probably cast this before using)
|
||||||
|
*/
|
||||||
|
fun getSetting(key: String): AbstractSetting? {
|
||||||
|
return settings[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mergeSection(settingSection: SettingSection) {
|
||||||
|
for (setting in settingSection.settings.values) {
|
||||||
|
putSetting(setting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,158 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
import android.text.TextUtils
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class Settings {
|
||||||
|
private var gameId: String? = null
|
||||||
|
|
||||||
|
var isLoaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A HashMap<String></String>, SettingSection> that constructs a new SettingSection instead of returning null
|
||||||
|
* when getting a key not already in the map
|
||||||
|
*/
|
||||||
|
class SettingsSectionMap : HashMap<String, SettingSection?>() {
|
||||||
|
override operator fun get(key: String): SettingSection? {
|
||||||
|
if (!super.containsKey(key)) {
|
||||||
|
val section = SettingSection(key)
|
||||||
|
super.put(key, section)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
return super.get(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sections: HashMap<String, SettingSection?> = SettingsSectionMap()
|
||||||
|
|
||||||
|
fun getSection(sectionName: String): SettingSection? {
|
||||||
|
return sections[sectionName]
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = sections.isEmpty()
|
||||||
|
|
||||||
|
fun loadSettings(view: SettingsActivityView? = null) {
|
||||||
|
sections = SettingsSectionMap()
|
||||||
|
loadYuzuSettings(view)
|
||||||
|
if (!TextUtils.isEmpty(gameId)) {
|
||||||
|
loadCustomGameSettings(gameId!!, view)
|
||||||
|
}
|
||||||
|
isLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadYuzuSettings(view: SettingsActivityView?) {
|
||||||
|
for ((fileName) in configFileSectionsMap) {
|
||||||
|
sections.putAll(SettingsFile.readFile(fileName, view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadCustomGameSettings(gameId: String, view: SettingsActivityView?) {
|
||||||
|
// Custom game settings
|
||||||
|
mergeSections(SettingsFile.readCustomGameSettings(gameId, view))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeSections(updatedSections: HashMap<String, SettingSection?>) {
|
||||||
|
for ((key, updatedSection) in updatedSections) {
|
||||||
|
if (sections.containsKey(key)) {
|
||||||
|
val originalSection = sections[key]
|
||||||
|
originalSection!!.mergeSection(updatedSection!!)
|
||||||
|
} else {
|
||||||
|
sections[key] = updatedSection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSettings(gameId: String, view: SettingsActivityView) {
|
||||||
|
this.gameId = gameId
|
||||||
|
loadSettings(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveSettings(view: SettingsActivityView) {
|
||||||
|
if (TextUtils.isEmpty(gameId)) {
|
||||||
|
view.showToastMessage(
|
||||||
|
YuzuApplication.appContext.getString(R.string.ini_saved),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
for ((fileName, sectionNames) in configFileSectionsMap) {
|
||||||
|
val iniSections = TreeMap<String, SettingSection>()
|
||||||
|
for (section in sectionNames) {
|
||||||
|
iniSections[section] = sections[section]!!
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsFile.saveFile(fileName, iniSections, view)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Custom game settings
|
||||||
|
view.showToastMessage(
|
||||||
|
YuzuApplication.appContext.getString(R.string.gameid_saved, gameId),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsFile.saveCustomGameSettings(gameId, sections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SECTION_GENERAL = "General"
|
||||||
|
const val SECTION_SYSTEM = "System"
|
||||||
|
const val SECTION_RENDERER = "Renderer"
|
||||||
|
const val SECTION_AUDIO = "Audio"
|
||||||
|
const val SECTION_CPU = "Cpu"
|
||||||
|
const val SECTION_THEME = "Theme"
|
||||||
|
const val SECTION_DEBUG = "Debug"
|
||||||
|
|
||||||
|
const val PREF_OVERLAY_INIT = "OverlayInit"
|
||||||
|
const val PREF_CONTROL_SCALE = "controlScale"
|
||||||
|
const val PREF_CONTROL_OPACITY = "controlOpacity"
|
||||||
|
const val PREF_TOUCH_ENABLED = "isTouchEnabled"
|
||||||
|
const val PREF_BUTTON_TOGGLE_0 = "buttonToggle0"
|
||||||
|
const val PREF_BUTTON_TOGGLE_1 = "buttonToggle1"
|
||||||
|
const val PREF_BUTTON_TOGGLE_2 = "buttonToggle2"
|
||||||
|
const val PREF_BUTTON_TOGGLE_3 = "buttonToggle3"
|
||||||
|
const val PREF_BUTTON_TOGGLE_4 = "buttonToggle4"
|
||||||
|
const val PREF_BUTTON_TOGGLE_5 = "buttonToggle5"
|
||||||
|
const val PREF_BUTTON_TOGGLE_6 = "buttonToggle6"
|
||||||
|
const val PREF_BUTTON_TOGGLE_7 = "buttonToggle7"
|
||||||
|
const val PREF_BUTTON_TOGGLE_8 = "buttonToggle8"
|
||||||
|
const val PREF_BUTTON_TOGGLE_9 = "buttonToggle9"
|
||||||
|
const val PREF_BUTTON_TOGGLE_10 = "buttonToggle10"
|
||||||
|
const val PREF_BUTTON_TOGGLE_11 = "buttonToggle11"
|
||||||
|
const val PREF_BUTTON_TOGGLE_12 = "buttonToggle12"
|
||||||
|
const val PREF_BUTTON_TOGGLE_13 = "buttonToggle13"
|
||||||
|
const val PREF_BUTTON_TOGGLE_14 = "buttonToggle14"
|
||||||
|
|
||||||
|
const val PREF_MENU_SETTINGS_JOYSTICK_REL_CENTER = "EmulationMenuSettings_JoystickRelCenter"
|
||||||
|
const val PREF_MENU_SETTINGS_DPAD_SLIDE = "EmulationMenuSettings_DpadSlideEnable"
|
||||||
|
const val PREF_MENU_SETTINGS_HAPTICS = "EmulationMenuSettings_Haptics"
|
||||||
|
const val PREF_MENU_SETTINGS_LANDSCAPE = "EmulationMenuSettings_LandscapeScreenLayout"
|
||||||
|
const val PREF_MENU_SETTINGS_SHOW_FPS = "EmulationMenuSettings_ShowFps"
|
||||||
|
const val PREF_MENU_SETTINGS_SHOW_OVERLAY = "EmulationMenuSettings_ShowOverlay"
|
||||||
|
|
||||||
|
const val PREF_FIRST_APP_LAUNCH = "FirstApplicationLaunch"
|
||||||
|
const val PREF_THEME = "Theme"
|
||||||
|
const val PREF_THEME_MODE = "ThemeMode"
|
||||||
|
const val PREF_BLACK_BACKGROUNDS = "BlackBackgrounds"
|
||||||
|
|
||||||
|
private val configFileSectionsMap: MutableMap<String, List<String>> = HashMap()
|
||||||
|
|
||||||
|
init {
|
||||||
|
configFileSectionsMap[SettingsFile.FILE_NAME_CONFIG] =
|
||||||
|
listOf(
|
||||||
|
SECTION_GENERAL,
|
||||||
|
SECTION_SYSTEM,
|
||||||
|
SECTION_RENDERER,
|
||||||
|
SECTION_AUDIO,
|
||||||
|
SECTION_CPU
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
|
class SettingsViewModel : ViewModel() {
|
||||||
|
val settings = Settings()
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model
|
||||||
|
|
||||||
|
enum class StringSetting(
|
||||||
|
override val key: String,
|
||||||
|
override val section: String,
|
||||||
|
override val defaultValue: String
|
||||||
|
) : AbstractStringSetting {
|
||||||
|
CUSTOM_RTC("custom_rtc", Settings.SECTION_SYSTEM, "0");
|
||||||
|
|
||||||
|
override var string: String = defaultValue
|
||||||
|
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = string
|
||||||
|
|
||||||
|
override val isRuntimeEditable: Boolean
|
||||||
|
get() {
|
||||||
|
for (setting in NOT_RUNTIME_EDITABLE) {
|
||||||
|
if (setting == this) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val NOT_RUNTIME_EDITABLE = listOf(
|
||||||
|
CUSTOM_RTC
|
||||||
|
)
|
||||||
|
|
||||||
|
fun from(key: String): StringSetting? = StringSetting.values().firstOrNull { it.key == key }
|
||||||
|
|
||||||
|
fun clear() = StringSetting.values().forEach { it.string = it.defaultValue }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
|
||||||
|
|
||||||
|
class DateTimeSetting(
|
||||||
|
setting: AbstractSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val key: String? = null,
|
||||||
|
private val defaultValue: String? = null
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_DATETIME_SETTING
|
||||||
|
|
||||||
|
val value: String
|
||||||
|
get() = if (setting != null) {
|
||||||
|
val setting = setting as AbstractStringSetting
|
||||||
|
setting.string
|
||||||
|
} else {
|
||||||
|
defaultValue!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSelectedValue(datetime: String): AbstractStringSetting {
|
||||||
|
val stringSetting = setting as AbstractStringSetting
|
||||||
|
stringSetting.string = datetime
|
||||||
|
return stringSetting
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
|
||||||
|
class HeaderSetting(
|
||||||
|
setting: AbstractSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_HEADER
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
class RunnableSetting(
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val isRuntimeRunnable: Boolean,
|
||||||
|
val runnable: () -> Unit
|
||||||
|
) : SettingsItem(null, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_RUNNABLE
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments.
|
||||||
|
* Each one corresponds to a [AbstractSetting] object, so this class's subclasses
|
||||||
|
* should vaguely correspond to those subclasses. There are a few with multiple analogues
|
||||||
|
* and a few with none (Headers, for example, do not correspond to anything in the ini
|
||||||
|
* file.)
|
||||||
|
*/
|
||||||
|
abstract class SettingsItem(
|
||||||
|
var setting: AbstractSetting?,
|
||||||
|
val nameId: Int,
|
||||||
|
val descriptionId: Int
|
||||||
|
) {
|
||||||
|
abstract val type: Int
|
||||||
|
|
||||||
|
val isEditable: Boolean
|
||||||
|
get() {
|
||||||
|
if (!NativeLibrary.isRunning()) return true
|
||||||
|
return setting?.isRuntimeEditable ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TYPE_HEADER = 0
|
||||||
|
const val TYPE_SWITCH = 1
|
||||||
|
const val TYPE_SINGLE_CHOICE = 2
|
||||||
|
const val TYPE_SLIDER = 3
|
||||||
|
const val TYPE_SUBMENU = 4
|
||||||
|
const val TYPE_STRING_SINGLE_CHOICE = 5
|
||||||
|
const val TYPE_DATETIME_SETTING = 6
|
||||||
|
const val TYPE_RUNNABLE = 7
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
|
|
||||||
|
class SingleChoiceSetting(
|
||||||
|
setting: AbstractIntSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val choicesId: Int,
|
||||||
|
val valuesId: Int,
|
||||||
|
val key: String? = null,
|
||||||
|
val defaultValue: Int? = null
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_SINGLE_CHOICE
|
||||||
|
|
||||||
|
val selectedValue: Int
|
||||||
|
get() = if (setting != null) {
|
||||||
|
val setting = setting as AbstractIntSetting
|
||||||
|
setting.int
|
||||||
|
} else {
|
||||||
|
defaultValue!!
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a value to the backing int. If that int was previously null,
|
||||||
|
* initializes a new one and returns it, so it can be added to the Hashmap.
|
||||||
|
*
|
||||||
|
* @param selection New value of the int.
|
||||||
|
* @return the existing setting with the new value applied.
|
||||||
|
*/
|
||||||
|
fun setSelectedValue(selection: Int): AbstractIntSetting {
|
||||||
|
val intSetting = setting as AbstractIntSetting
|
||||||
|
intSetting.int = selection
|
||||||
|
return intSetting
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
class SliderSetting(
|
||||||
|
setting: AbstractSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val min: Int,
|
||||||
|
val max: Int,
|
||||||
|
val units: String,
|
||||||
|
val key: String? = null,
|
||||||
|
val defaultValue: Int? = null,
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_SLIDER
|
||||||
|
|
||||||
|
val selectedValue: Int
|
||||||
|
get() {
|
||||||
|
val setting = setting ?: return defaultValue!!
|
||||||
|
return when (setting) {
|
||||||
|
is AbstractIntSetting -> setting.int
|
||||||
|
is AbstractFloatSetting -> setting.float.roundToInt()
|
||||||
|
else -> {
|
||||||
|
Log.error("[SliderSetting] Error casting setting type.")
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a value to the backing int. If that int was previously null,
|
||||||
|
* initializes a new one and returns it, so it can be added to the Hashmap.
|
||||||
|
*
|
||||||
|
* @param selection New value of the int.
|
||||||
|
* @return the existing setting with the new value applied.
|
||||||
|
*/
|
||||||
|
fun setSelectedValue(selection: Int): AbstractIntSetting {
|
||||||
|
val intSetting = setting as AbstractIntSetting
|
||||||
|
intSetting.int = selection
|
||||||
|
return intSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a value to the backing float. If that float was previously null,
|
||||||
|
* initializes a new one and returns it, so it can be added to the Hashmap.
|
||||||
|
*
|
||||||
|
* @param selection New value of the float.
|
||||||
|
* @return the existing setting with the new value applied.
|
||||||
|
*/
|
||||||
|
fun setSelectedValue(selection: Float): AbstractFloatSetting {
|
||||||
|
val floatSetting = setting as AbstractFloatSetting
|
||||||
|
floatSetting.float = selection
|
||||||
|
return floatSetting
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
|
||||||
|
|
||||||
|
class StringSingleChoiceSetting(
|
||||||
|
val key: String? = null,
|
||||||
|
setting: AbstractSetting?,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val choicesId: Array<String>,
|
||||||
|
private val valuesId: Array<String>?,
|
||||||
|
private val defaultValue: String? = null
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_STRING_SINGLE_CHOICE
|
||||||
|
|
||||||
|
fun getValueAt(index: Int): String? {
|
||||||
|
if (valuesId == null) return null
|
||||||
|
return if (index >= 0 && index < valuesId.size) {
|
||||||
|
valuesId[index]
|
||||||
|
} else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedValue: String
|
||||||
|
get() = if (setting != null) {
|
||||||
|
val setting = setting as AbstractStringSetting
|
||||||
|
setting.string
|
||||||
|
} else {
|
||||||
|
defaultValue!!
|
||||||
|
}
|
||||||
|
val selectValueIndex: Int
|
||||||
|
get() {
|
||||||
|
val selectedValue = selectedValue
|
||||||
|
for (i in valuesId!!.indices) {
|
||||||
|
if (valuesId[i] == selectedValue) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a value to the backing int. If that int was previously null,
|
||||||
|
* initializes a new one and returns it, so it can be added to the Hashmap.
|
||||||
|
*
|
||||||
|
* @param selection New value of the int.
|
||||||
|
* @return the existing setting with the new value applied.
|
||||||
|
*/
|
||||||
|
fun setSelectedValue(selection: String): AbstractStringSetting {
|
||||||
|
val stringSetting = setting as AbstractStringSetting
|
||||||
|
stringSetting.string = selection
|
||||||
|
return stringSetting
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
|
||||||
|
class SubmenuSetting(
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val menuKey: String
|
||||||
|
) : SettingsItem(null, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_SUBMENU
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.model.view
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
|
||||||
|
class SwitchSetting(
|
||||||
|
setting: AbstractSetting,
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
val key: String? = null,
|
||||||
|
val defaultValue: Any? = null
|
||||||
|
) : SettingsItem(setting, titleId, descriptionId) {
|
||||||
|
override val type = TYPE_SWITCH
|
||||||
|
|
||||||
|
val isChecked: Boolean
|
||||||
|
get() {
|
||||||
|
if (setting == null) {
|
||||||
|
return defaultValue as Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try integer setting
|
||||||
|
try {
|
||||||
|
val setting = setting as AbstractIntSetting
|
||||||
|
return setting.int == 1
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try boolean setting
|
||||||
|
try {
|
||||||
|
val setting = setting as AbstractBooleanSetting
|
||||||
|
return setting.boolean
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
}
|
||||||
|
return defaultValue as Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a value to the backing boolean. If that boolean was previously null,
|
||||||
|
* initializes a new one and returns it, so it can be added to the Hashmap.
|
||||||
|
*
|
||||||
|
* @param checked Pretty self explanatory.
|
||||||
|
* @return the existing setting with the new value applied.
|
||||||
|
*/
|
||||||
|
fun setChecked(checked: Boolean): AbstractSetting {
|
||||||
|
// Try integer setting
|
||||||
|
try {
|
||||||
|
val setting = setting as AbstractIntSetting
|
||||||
|
setting.int = if (checked) 1 else 0
|
||||||
|
return setting
|
||||||
|
} catch (_: ClassCastException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try boolean setting
|
||||||
|
val setting = setting as AbstractBooleanSetting
|
||||||
|
setting.boolean = checked
|
||||||
|
return setting
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,243 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ActivitySettingsBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.utils.*
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class SettingsActivity : AppCompatActivity(), SettingsActivityView {
|
||||||
|
private val presenter = SettingsActivityPresenter(this)
|
||||||
|
|
||||||
|
private lateinit var binding: ActivitySettingsBinding
|
||||||
|
|
||||||
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
override val settings: Settings get() = settingsViewModel.settings
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.setTheme(this)
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
binding = ActivitySettingsBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
|
val launcher = intent
|
||||||
|
val gameID = launcher.getStringExtra(ARG_GAME_ID)
|
||||||
|
val menuTag = launcher.getStringExtra(ARG_MENU_TAG)
|
||||||
|
presenter.onCreate(savedInstanceState, menuTag!!, gameID!!)
|
||||||
|
|
||||||
|
// Show "Back" button in the action bar for navigation
|
||||||
|
setSupportActionBar(binding.toolbarSettings)
|
||||||
|
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
|
if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
|
||||||
|
binding.navigationBarShade.setBackgroundColor(
|
||||||
|
ThemeHelper.getColorWithOpacity(
|
||||||
|
MaterialColors.getColor(
|
||||||
|
binding.navigationBarShade,
|
||||||
|
com.google.android.material.R.attr.colorSurface
|
||||||
|
),
|
||||||
|
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(
|
||||||
|
this,
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() = navigateBack()
|
||||||
|
})
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSupportNavigateUp(): Boolean {
|
||||||
|
navigateBack()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateBack() {
|
||||||
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
|
supportFragmentManager.popBackStack()
|
||||||
|
} else {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
val inflater = menuInflater
|
||||||
|
inflater.inflate(R.menu.menu_settings, menu)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
// Critical: If super method is not called, rotations will be busted.
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
presenter.saveState(outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
presenter.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If this is called, the user has left the settings screen (potentially through the
|
||||||
|
* home button) and will expect their changes to be persisted. So we kick off an
|
||||||
|
* IntentService which will do so on a background thread.
|
||||||
|
*/
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
presenter.onStop(isFinishing)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String) {
|
||||||
|
if (!addToStack && settingsFragment != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val transaction = supportFragmentManager.beginTransaction()
|
||||||
|
if (addToStack) {
|
||||||
|
if (areSystemAnimationsEnabled()) {
|
||||||
|
transaction.setCustomAnimations(
|
||||||
|
R.anim.anim_settings_fragment_in,
|
||||||
|
R.anim.anim_settings_fragment_out,
|
||||||
|
0,
|
||||||
|
R.anim.anim_pop_settings_fragment_out
|
||||||
|
)
|
||||||
|
}
|
||||||
|
transaction.addToBackStack(null)
|
||||||
|
}
|
||||||
|
transaction.replace(
|
||||||
|
R.id.frame_content,
|
||||||
|
SettingsFragment.newInstance(menuTag, gameId),
|
||||||
|
FRAGMENT_TAG
|
||||||
|
)
|
||||||
|
transaction.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun areSystemAnimationsEnabled(): Boolean {
|
||||||
|
val duration = android.provider.Settings.Global.getFloat(
|
||||||
|
contentResolver,
|
||||||
|
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f
|
||||||
|
)
|
||||||
|
val transition = android.provider.Settings.Global.getFloat(
|
||||||
|
contentResolver,
|
||||||
|
android.provider.Settings.Global.TRANSITION_ANIMATION_SCALE, 1f
|
||||||
|
)
|
||||||
|
return duration != 0f && transition != 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSettingsFileLoaded() {
|
||||||
|
val fragment: SettingsFragmentView? = settingsFragment
|
||||||
|
fragment?.loadSettingsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSettingsFileNotFound() {
|
||||||
|
val fragment: SettingsFragmentView? = settingsFragment
|
||||||
|
fragment?.loadSettingsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showToastMessage(message: String, is_long: Boolean) {
|
||||||
|
Toast.makeText(
|
||||||
|
this,
|
||||||
|
message,
|
||||||
|
if (is_long) Toast.LENGTH_LONG else Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSettingChanged() {
|
||||||
|
presenter.onSettingChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSettingsReset() {
|
||||||
|
// Prevents saving to a non-existent settings file
|
||||||
|
presenter.onSettingsReset()
|
||||||
|
|
||||||
|
// Reset the static memory representation of each setting
|
||||||
|
BooleanSetting.clear()
|
||||||
|
FloatSetting.clear()
|
||||||
|
IntSetting.clear()
|
||||||
|
StringSetting.clear()
|
||||||
|
|
||||||
|
// Delete settings file because the user may have changed values that do not exist in the UI
|
||||||
|
val settingsFile = SettingsFile.getSettingsFile(SettingsFile.FILE_NAME_CONFIG)
|
||||||
|
if (!settingsFile.delete()) {
|
||||||
|
throw IOException("Failed to delete $settingsFile")
|
||||||
|
}
|
||||||
|
|
||||||
|
showToastMessage(getString(R.string.settings_reset), true)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setToolbarTitle(title: String) {
|
||||||
|
binding.toolbarSettingsLayout.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
private val settingsFragment: SettingsFragment?
|
||||||
|
get() = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as SettingsFragment?
|
||||||
|
|
||||||
|
private fun setInsets() {
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.frameContent) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
view.updatePadding(
|
||||||
|
left = barInsets.left + cutoutInsets.left,
|
||||||
|
right = barInsets.right + cutoutInsets.right
|
||||||
|
)
|
||||||
|
|
||||||
|
val mlpAppBar = binding.appbarSettings.layoutParams as MarginLayoutParams
|
||||||
|
mlpAppBar.leftMargin = barInsets.left + cutoutInsets.left
|
||||||
|
mlpAppBar.rightMargin = barInsets.right + cutoutInsets.right
|
||||||
|
binding.appbarSettings.layoutParams = mlpAppBar
|
||||||
|
|
||||||
|
val mlpShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
|
||||||
|
mlpShade.height = barInsets.bottom
|
||||||
|
binding.navigationBarShade.layoutParams = mlpShade
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_MENU_TAG = "menu_tag"
|
||||||
|
private const val ARG_GAME_ID = "game_id"
|
||||||
|
private const val FRAGMENT_TAG = "settings"
|
||||||
|
|
||||||
|
fun launch(context: Context, menuTag: String?, gameId: String?) {
|
||||||
|
val settings = Intent(context, SettingsActivity::class.java)
|
||||||
|
settings.putExtra(ARG_MENU_TAG, menuTag)
|
||||||
|
settings.putExtra(ARG_GAME_ID, gameId)
|
||||||
|
context.startActivity(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.TextUtils
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SettingsActivityPresenter(private val activityView: SettingsActivityView) {
|
||||||
|
val settings: Settings get() = activityView.settings
|
||||||
|
|
||||||
|
private var shouldSave = false
|
||||||
|
private lateinit var menuTag: String
|
||||||
|
private lateinit var gameId: String
|
||||||
|
|
||||||
|
fun onCreate(savedInstanceState: Bundle?, menuTag: String, gameId: String) {
|
||||||
|
this.menuTag = menuTag
|
||||||
|
this.gameId = gameId
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
shouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStart() {
|
||||||
|
prepareDirectoriesIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettingsUI() {
|
||||||
|
if (!settings.isLoaded) {
|
||||||
|
if (!TextUtils.isEmpty(gameId)) {
|
||||||
|
settings.loadSettings(gameId, activityView)
|
||||||
|
} else {
|
||||||
|
settings.loadSettings(activityView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activityView.showSettingsFragment(menuTag, false, gameId)
|
||||||
|
activityView.onSettingsFileLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareDirectoriesIfNeeded() {
|
||||||
|
val configFile =
|
||||||
|
File(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
|
||||||
|
if (!configFile.exists()) {
|
||||||
|
Log.error(DirectoryInitialization.userDirectory + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini")
|
||||||
|
Log.error("yuzu config file could not be found!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DirectoryInitialization.areDirectoriesReady) {
|
||||||
|
DirectoryInitialization.start(activityView as Context)
|
||||||
|
}
|
||||||
|
loadSettingsUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStop(finishing: Boolean) {
|
||||||
|
if (finishing && shouldSave) {
|
||||||
|
Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI...")
|
||||||
|
settings.saveSettings(activityView)
|
||||||
|
}
|
||||||
|
NativeLibrary.reloadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSettingChanged() {
|
||||||
|
shouldSave = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSettingsReset() {
|
||||||
|
shouldSave = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveState(outState: Bundle) {
|
||||||
|
outState.putBoolean(KEY_SHOULD_SAVE, shouldSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_SHOULD_SAVE = "should_save"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for the Activity that manages SettingsFragments.
|
||||||
|
*/
|
||||||
|
interface SettingsActivityView {
|
||||||
|
/**
|
||||||
|
* Show a new SettingsFragment.
|
||||||
|
*
|
||||||
|
* @param menuTag Identifier for the settings group that should be displayed.
|
||||||
|
* @param addToStack Whether or not this fragment should replace a previous one.
|
||||||
|
*/
|
||||||
|
fun showSettingsFragment(menuTag: String, addToStack: Boolean, gameId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by a contained Fragment to get access to the Setting HashMap
|
||||||
|
* loaded from disk, so that each Fragment doesn't need to perform its own
|
||||||
|
* read operation.
|
||||||
|
*
|
||||||
|
* @return A HashMap of Settings.
|
||||||
|
*/
|
||||||
|
val settings: Settings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a load operation completes.
|
||||||
|
*/
|
||||||
|
fun onSettingsFileLoaded()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a load operation fails.
|
||||||
|
*/
|
||||||
|
fun onSettingsFileNotFound()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a popup text message on screen.
|
||||||
|
*
|
||||||
|
* @param message The contents of the onscreen message.
|
||||||
|
* @param is_long Whether this should be a long Toast or short one.
|
||||||
|
*/
|
||||||
|
fun showToastMessage(message: String, is_long: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the activity.
|
||||||
|
*/
|
||||||
|
fun finish()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by a containing Fragment to tell the Activity that a setting was changed;
|
||||||
|
* unless this has been called, the Activity will not save to disk.
|
||||||
|
*/
|
||||||
|
fun onSettingChanged()
|
||||||
|
}
|
@ -0,0 +1,340 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.icu.util.Calendar
|
||||||
|
import android.icu.util.TimeZone
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.setFragmentResultListener
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.datepicker.MaterialDatePicker
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.slider.Slider
|
||||||
|
import com.google.android.material.timepicker.MaterialTimePicker
|
||||||
|
import com.google.android.material.timepicker.TimeFormat
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogSliderBinding
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractFloatSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractStringSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.FloatSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.*
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.viewholder.*
|
||||||
|
|
||||||
|
class SettingsAdapter(
|
||||||
|
private val fragmentView: SettingsFragmentView,
|
||||||
|
private val context: Context
|
||||||
|
) : RecyclerView.Adapter<SettingViewHolder?>(), DialogInterface.OnClickListener {
|
||||||
|
private var settings: ArrayList<SettingsItem>? = null
|
||||||
|
private var clickedItem: SettingsItem? = null
|
||||||
|
private var clickedPosition: Int
|
||||||
|
private var dialog: AlertDialog? = null
|
||||||
|
private var sliderProgress = 0
|
||||||
|
private var textSliderValue: TextView? = null
|
||||||
|
|
||||||
|
private var defaultCancelListener =
|
||||||
|
DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> closeDialog() }
|
||||||
|
|
||||||
|
init {
|
||||||
|
clickedPosition = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
SettingsItem.TYPE_HEADER -> {
|
||||||
|
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_SWITCH -> {
|
||||||
|
SwitchSettingViewHolder(ListItemSettingSwitchBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_SINGLE_CHOICE, SettingsItem.TYPE_STRING_SINGLE_CHOICE -> {
|
||||||
|
SingleChoiceViewHolder(ListItemSettingBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_SLIDER -> {
|
||||||
|
SliderViewHolder(ListItemSettingBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_SUBMENU -> {
|
||||||
|
SubmenuViewHolder(ListItemSettingBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_DATETIME_SETTING -> {
|
||||||
|
DateTimeViewHolder(ListItemSettingBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsItem.TYPE_RUNNABLE -> {
|
||||||
|
RunnableViewHolder(ListItemSettingBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// TODO: Create an error view since we can't return null now
|
||||||
|
HeaderViewHolder(ListItemSettingsHeaderBinding.inflate(inflater), this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: SettingViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getItem(position: Int): SettingsItem {
|
||||||
|
return settings!![position]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
return if (settings != null) {
|
||||||
|
settings!!.size
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
return getItem(position).type
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSettingsList(settings: ArrayList<SettingsItem>?) {
|
||||||
|
this.settings = settings
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onBooleanClick(item: SwitchSetting, position: Int, checked: Boolean) {
|
||||||
|
val setting = item.setChecked(checked)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
fragmentView.onSettingChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSingleChoiceClick(item: SingleChoiceSetting) {
|
||||||
|
clickedItem = item
|
||||||
|
val value = getSelectionForSingleChoiceValue(item)
|
||||||
|
dialog = MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(item.nameId)
|
||||||
|
.setSingleChoiceItems(item.choicesId, value, this)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSingleChoiceClick(item: SingleChoiceSetting, position: Int) {
|
||||||
|
clickedPosition = position
|
||||||
|
onSingleChoiceClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStringSingleChoiceClick(item: StringSingleChoiceSetting) {
|
||||||
|
clickedItem = item
|
||||||
|
dialog = MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(item.nameId)
|
||||||
|
.setSingleChoiceItems(item.choicesId, item.selectValueIndex, this)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onStringSingleChoiceClick(item: StringSingleChoiceSetting, position: Int) {
|
||||||
|
clickedPosition = position
|
||||||
|
onStringSingleChoiceClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onDateTimeClick(item: DateTimeSetting, position: Int) {
|
||||||
|
clickedItem = item
|
||||||
|
clickedPosition = position
|
||||||
|
val storedTime = java.lang.Long.decode(item.value) * 1000
|
||||||
|
|
||||||
|
// Helper to extract hour and minute from epoch time
|
||||||
|
val calendar: Calendar = Calendar.getInstance()
|
||||||
|
calendar.timeInMillis = storedTime
|
||||||
|
calendar.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
|
||||||
|
var timeFormat: Int = TimeFormat.CLOCK_12H
|
||||||
|
if (DateFormat.is24HourFormat(fragmentView.activityView as AppCompatActivity)) {
|
||||||
|
timeFormat = TimeFormat.CLOCK_24H
|
||||||
|
}
|
||||||
|
|
||||||
|
val datePicker: MaterialDatePicker<Long> = MaterialDatePicker.Builder.datePicker()
|
||||||
|
.setSelection(storedTime)
|
||||||
|
.setTitleText(R.string.select_rtc_date)
|
||||||
|
.build()
|
||||||
|
val timePicker: MaterialTimePicker = MaterialTimePicker.Builder()
|
||||||
|
.setTimeFormat(timeFormat)
|
||||||
|
.setHour(calendar.get(Calendar.HOUR_OF_DAY))
|
||||||
|
.setMinute(calendar.get(Calendar.MINUTE))
|
||||||
|
.setTitleText(R.string.select_rtc_time)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
datePicker.addOnPositiveButtonClickListener {
|
||||||
|
timePicker.show(
|
||||||
|
(fragmentView.activityView as AppCompatActivity).supportFragmentManager,
|
||||||
|
"TimePicker"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
timePicker.addOnPositiveButtonClickListener {
|
||||||
|
var epochTime: Long = datePicker.selection!! / 1000
|
||||||
|
epochTime += timePicker.hour.toLong() * 60 * 60
|
||||||
|
epochTime += timePicker.minute.toLong() * 60
|
||||||
|
val rtcString = epochTime.toString()
|
||||||
|
if (item.value != rtcString) {
|
||||||
|
fragmentView.onSettingChanged()
|
||||||
|
}
|
||||||
|
notifyItemChanged(clickedPosition)
|
||||||
|
val setting = item.setSelectedValue(rtcString)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
clickedItem = null
|
||||||
|
}
|
||||||
|
datePicker.show(
|
||||||
|
(fragmentView.activityView as AppCompatActivity).supportFragmentManager,
|
||||||
|
"DatePicker"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSliderClick(item: SliderSetting, position: Int) {
|
||||||
|
clickedItem = item
|
||||||
|
clickedPosition = position
|
||||||
|
sliderProgress = item.selectedValue
|
||||||
|
|
||||||
|
val inflater = LayoutInflater.from(context)
|
||||||
|
val sliderBinding = DialogSliderBinding.inflate(inflater)
|
||||||
|
|
||||||
|
textSliderValue = sliderBinding.textValue
|
||||||
|
textSliderValue!!.text = sliderProgress.toString()
|
||||||
|
sliderBinding.textUnits.text = item.units
|
||||||
|
|
||||||
|
sliderBinding.slider.apply {
|
||||||
|
valueFrom = item.min.toFloat()
|
||||||
|
valueTo = item.max.toFloat()
|
||||||
|
value = sliderProgress.toFloat()
|
||||||
|
addOnChangeListener { _: Slider, value: Float, _: Boolean ->
|
||||||
|
sliderProgress = value.toInt()
|
||||||
|
textSliderValue!!.text = sliderProgress.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog = MaterialAlertDialogBuilder(context)
|
||||||
|
.setTitle(item.nameId)
|
||||||
|
.setView(sliderBinding.root)
|
||||||
|
.setPositiveButton(android.R.string.ok, this)
|
||||||
|
.setNegativeButton(android.R.string.cancel, defaultCancelListener)
|
||||||
|
.setNeutralButton(R.string.slider_default) { dialog: DialogInterface, which: Int ->
|
||||||
|
sliderBinding.slider.value = item.defaultValue!!.toFloat()
|
||||||
|
onClick(dialog, which)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSubmenuClick(item: SubmenuSetting) {
|
||||||
|
fragmentView.loadSubMenu(item.menuKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(dialog: DialogInterface, which: Int) {
|
||||||
|
when (clickedItem) {
|
||||||
|
is SingleChoiceSetting -> {
|
||||||
|
val scSetting = clickedItem as SingleChoiceSetting
|
||||||
|
val value = getValueForSingleChoiceSelection(scSetting, which)
|
||||||
|
if (scSetting.selectedValue != value) {
|
||||||
|
fragmentView.onSettingChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the backing Setting, which may be null (if for example it was missing from the file)
|
||||||
|
val setting = scSetting.setSelectedValue(value)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
is StringSingleChoiceSetting -> {
|
||||||
|
val scSetting = clickedItem as StringSingleChoiceSetting
|
||||||
|
val value = scSetting.getValueAt(which)
|
||||||
|
if (scSetting.selectedValue != value) fragmentView.onSettingChanged()
|
||||||
|
val setting = scSetting.setSelectedValue(value!!)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
is SliderSetting -> {
|
||||||
|
val sliderSetting = clickedItem as SliderSetting
|
||||||
|
if (sliderSetting.selectedValue != sliderProgress) {
|
||||||
|
fragmentView.onSettingChanged()
|
||||||
|
}
|
||||||
|
if (sliderSetting.setting is FloatSetting) {
|
||||||
|
val value = sliderProgress.toFloat()
|
||||||
|
val setting = sliderSetting.setSelectedValue(value)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
} else {
|
||||||
|
val setting = sliderSetting.setSelectedValue(sliderProgress)
|
||||||
|
fragmentView.putSetting(setting)
|
||||||
|
}
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clickedItem = null
|
||||||
|
sliderProgress = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLongClick(setting: AbstractSetting, position: Int): Boolean {
|
||||||
|
MaterialAlertDialogBuilder(context)
|
||||||
|
.setMessage(R.string.reset_setting_confirmation)
|
||||||
|
.setPositiveButton(android.R.string.ok) { dialog: DialogInterface, which: Int ->
|
||||||
|
when (setting) {
|
||||||
|
is AbstractBooleanSetting -> setting.boolean = setting.defaultValue as Boolean
|
||||||
|
is AbstractFloatSetting -> setting.float = setting.defaultValue as Float
|
||||||
|
is AbstractIntSetting -> setting.int = setting.defaultValue as Int
|
||||||
|
is AbstractStringSetting -> setting.string = setting.defaultValue as String
|
||||||
|
}
|
||||||
|
notifyItemChanged(position)
|
||||||
|
fragmentView.onSettingChanged()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeDialog() {
|
||||||
|
if (dialog != null) {
|
||||||
|
if (clickedPosition != -1) {
|
||||||
|
notifyItemChanged(clickedPosition)
|
||||||
|
clickedPosition = -1
|
||||||
|
}
|
||||||
|
dialog!!.dismiss()
|
||||||
|
dialog = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getValueForSingleChoiceSelection(item: SingleChoiceSetting, which: Int): Int {
|
||||||
|
val valuesId = item.valuesId
|
||||||
|
return if (valuesId > 0) {
|
||||||
|
val valuesArray = context.resources.getIntArray(valuesId)
|
||||||
|
valuesArray[which]
|
||||||
|
} else {
|
||||||
|
which
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSelectionForSingleChoiceValue(item: SingleChoiceSetting): Int {
|
||||||
|
val value = item.selectedValue
|
||||||
|
val valuesId = item.valuesId
|
||||||
|
if (valuesId > 0) {
|
||||||
|
val valuesArray = context.resources.getIntArray(valuesId)
|
||||||
|
for (index in valuesArray.indices) {
|
||||||
|
val current = valuesArray[index]
|
||||||
|
if (current == value) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,122 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.divider.MaterialDividerItemDecoration
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentSettingsBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
|
||||||
|
class SettingsFragment : Fragment(), SettingsFragmentView {
|
||||||
|
override var activityView: SettingsActivityView? = null
|
||||||
|
|
||||||
|
private val fragmentPresenter = SettingsFragmentPresenter(this)
|
||||||
|
private var settingsAdapter: SettingsAdapter? = null
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
activityView = requireActivity() as SettingsActivityView
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val menuTag = requireArguments().getString(ARGUMENT_MENU_TAG)
|
||||||
|
val gameId = requireArguments().getString(ARGUMENT_GAME_ID)
|
||||||
|
fragmentPresenter.onCreate(menuTag!!, gameId!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSettingsBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
settingsAdapter = SettingsAdapter(this, requireActivity())
|
||||||
|
val dividerDecoration = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)
|
||||||
|
dividerDecoration.isLastItemDecorated = false
|
||||||
|
binding.listSettings.apply {
|
||||||
|
adapter = settingsAdapter
|
||||||
|
layoutManager = LinearLayoutManager(activity)
|
||||||
|
addItemDecoration(dividerDecoration)
|
||||||
|
}
|
||||||
|
fragmentPresenter.onViewCreated()
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetach() {
|
||||||
|
super.onDetach()
|
||||||
|
activityView = null
|
||||||
|
if (settingsAdapter != null) {
|
||||||
|
settingsAdapter!!.closeDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showSettingsList(settingsList: ArrayList<SettingsItem>) {
|
||||||
|
settingsAdapter!!.setSettingsList(settingsList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadSettingsList() {
|
||||||
|
fragmentPresenter.loadSettingsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadSubMenu(menuKey: String) {
|
||||||
|
activityView!!.showSettingsFragment(
|
||||||
|
menuKey,
|
||||||
|
true,
|
||||||
|
requireArguments().getString(ARGUMENT_GAME_ID)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showToastMessage(message: String?, is_long: Boolean) {
|
||||||
|
activityView!!.showToastMessage(message!!, is_long)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun putSetting(setting: AbstractSetting) {
|
||||||
|
fragmentPresenter.putSetting(setting)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSettingChanged() {
|
||||||
|
activityView!!.onSettingChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() {
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.listSettings) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.updatePadding(bottom = insets.bottom)
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARGUMENT_MENU_TAG = "menu_tag"
|
||||||
|
private const val ARGUMENT_GAME_ID = "game_id"
|
||||||
|
|
||||||
|
fun newInstance(menuTag: String?, gameId: String?): Fragment {
|
||||||
|
val fragment = SettingsFragment()
|
||||||
|
val arguments = Bundle()
|
||||||
|
arguments.putString(ARGUMENT_MENU_TAG, menuTag)
|
||||||
|
arguments.putString(ARGUMENT_GAME_ID, gameId)
|
||||||
|
fragment.arguments = arguments
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,465 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.TextUtils
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractBooleanSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractIntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.BooleanSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.StringSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.*
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.fragments.ResetSettingsDialogFragment
|
||||||
|
import org.yuzu.yuzu_emu.utils.ThemeHelper
|
||||||
|
|
||||||
|
class SettingsFragmentPresenter(private val fragmentView: SettingsFragmentView) {
|
||||||
|
private var menuTag: String? = null
|
||||||
|
private lateinit var gameId: String
|
||||||
|
private var settingsList: ArrayList<SettingsItem>? = null
|
||||||
|
|
||||||
|
private val settingsActivity get() = fragmentView.activityView as SettingsActivity
|
||||||
|
private val settings get() = fragmentView.activityView!!.settings
|
||||||
|
|
||||||
|
private lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
fun onCreate(menuTag: String, gameId: String) {
|
||||||
|
this.gameId = gameId
|
||||||
|
this.menuTag = menuTag
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onViewCreated() {
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
loadSettingsList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putSetting(setting: AbstractSetting) {
|
||||||
|
if (setting.section == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val section = settings.getSection(setting.section!!)!!
|
||||||
|
if (section.getSetting(setting.key!!) == null) {
|
||||||
|
section.putSetting(setting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSettingsList() {
|
||||||
|
if (!TextUtils.isEmpty(gameId)) {
|
||||||
|
settingsActivity.setToolbarTitle("Game Settings: $gameId")
|
||||||
|
}
|
||||||
|
val sl = ArrayList<SettingsItem>()
|
||||||
|
if (menuTag == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (menuTag) {
|
||||||
|
SettingsFile.FILE_NAME_CONFIG -> addConfigSettings(sl)
|
||||||
|
Settings.SECTION_GENERAL -> addGeneralSettings(sl)
|
||||||
|
Settings.SECTION_SYSTEM -> addSystemSettings(sl)
|
||||||
|
Settings.SECTION_RENDERER -> addGraphicsSettings(sl)
|
||||||
|
Settings.SECTION_AUDIO -> addAudioSettings(sl)
|
||||||
|
Settings.SECTION_THEME -> addThemeSettings(sl)
|
||||||
|
Settings.SECTION_DEBUG -> addDebugSettings(sl)
|
||||||
|
else -> {
|
||||||
|
fragmentView.showToastMessage("Unimplemented menu", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
settingsList = sl
|
||||||
|
fragmentView.showSettingsList(settingsList!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addConfigSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.advanced_settings))
|
||||||
|
sl.apply {
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
R.string.preferences_general,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_GENERAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
R.string.preferences_system,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_SYSTEM
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
R.string.preferences_graphics,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_RENDERER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
R.string.preferences_audio,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_AUDIO
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SubmenuSetting(
|
||||||
|
R.string.preferences_debug,
|
||||||
|
0,
|
||||||
|
Settings.SECTION_DEBUG
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
RunnableSetting(
|
||||||
|
R.string.reset_to_default,
|
||||||
|
0,
|
||||||
|
false
|
||||||
|
) {
|
||||||
|
ResetSettingsDialogFragment().show(
|
||||||
|
settingsActivity.supportFragmentManager,
|
||||||
|
ResetSettingsDialogFragment.TAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addGeneralSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_general))
|
||||||
|
sl.apply {
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_USE_SPEED_LIMIT,
|
||||||
|
R.string.frame_limit_enable,
|
||||||
|
R.string.frame_limit_enable_description,
|
||||||
|
IntSetting.RENDERER_USE_SPEED_LIMIT.key,
|
||||||
|
IntSetting.RENDERER_USE_SPEED_LIMIT.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SliderSetting(
|
||||||
|
IntSetting.RENDERER_SPEED_LIMIT,
|
||||||
|
R.string.frame_limit_slider,
|
||||||
|
R.string.frame_limit_slider_description,
|
||||||
|
1,
|
||||||
|
200,
|
||||||
|
"%",
|
||||||
|
IntSetting.RENDERER_SPEED_LIMIT.key,
|
||||||
|
IntSetting.RENDERER_SPEED_LIMIT.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.CPU_ACCURACY,
|
||||||
|
R.string.cpu_accuracy,
|
||||||
|
0,
|
||||||
|
R.array.cpuAccuracyNames,
|
||||||
|
R.array.cpuAccuracyValues,
|
||||||
|
IntSetting.CPU_ACCURACY.key,
|
||||||
|
IntSetting.CPU_ACCURACY.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSystemSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_system))
|
||||||
|
sl.apply {
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.USE_DOCKED_MODE,
|
||||||
|
R.string.use_docked_mode,
|
||||||
|
R.string.use_docked_mode_description,
|
||||||
|
IntSetting.USE_DOCKED_MODE.key,
|
||||||
|
IntSetting.USE_DOCKED_MODE.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.REGION_INDEX,
|
||||||
|
R.string.emulated_region,
|
||||||
|
0,
|
||||||
|
R.array.regionNames,
|
||||||
|
R.array.regionValues,
|
||||||
|
IntSetting.REGION_INDEX.key,
|
||||||
|
IntSetting.REGION_INDEX.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.LANGUAGE_INDEX,
|
||||||
|
R.string.emulated_language,
|
||||||
|
0,
|
||||||
|
R.array.languageNames,
|
||||||
|
R.array.languageValues,
|
||||||
|
IntSetting.LANGUAGE_INDEX.key,
|
||||||
|
IntSetting.LANGUAGE_INDEX.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
BooleanSetting.USE_CUSTOM_RTC,
|
||||||
|
R.string.use_custom_rtc,
|
||||||
|
R.string.use_custom_rtc_description,
|
||||||
|
BooleanSetting.USE_CUSTOM_RTC.key,
|
||||||
|
BooleanSetting.USE_CUSTOM_RTC.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
DateTimeSetting(
|
||||||
|
StringSetting.CUSTOM_RTC,
|
||||||
|
R.string.set_custom_rtc,
|
||||||
|
0,
|
||||||
|
StringSetting.CUSTOM_RTC.key,
|
||||||
|
StringSetting.CUSTOM_RTC.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addGraphicsSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_graphics))
|
||||||
|
sl.apply {
|
||||||
|
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_ACCURACY,
|
||||||
|
R.string.renderer_accuracy,
|
||||||
|
0,
|
||||||
|
R.array.rendererAccuracyNames,
|
||||||
|
R.array.rendererAccuracyValues,
|
||||||
|
IntSetting.RENDERER_ACCURACY.key,
|
||||||
|
IntSetting.RENDERER_ACCURACY.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_RESOLUTION,
|
||||||
|
R.string.renderer_resolution,
|
||||||
|
0,
|
||||||
|
R.array.rendererResolutionNames,
|
||||||
|
R.array.rendererResolutionValues,
|
||||||
|
IntSetting.RENDERER_RESOLUTION.key,
|
||||||
|
IntSetting.RENDERER_RESOLUTION.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_VSYNC,
|
||||||
|
R.string.renderer_vsync,
|
||||||
|
0,
|
||||||
|
R.array.rendererVSyncNames,
|
||||||
|
R.array.rendererVSyncValues,
|
||||||
|
IntSetting.RENDERER_VSYNC.key,
|
||||||
|
IntSetting.RENDERER_VSYNC.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_SCALING_FILTER,
|
||||||
|
R.string.renderer_scaling_filter,
|
||||||
|
0,
|
||||||
|
R.array.rendererScalingFilterNames,
|
||||||
|
R.array.rendererScalingFilterValues,
|
||||||
|
IntSetting.RENDERER_SCALING_FILTER.key,
|
||||||
|
IntSetting.RENDERER_SCALING_FILTER.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_ANTI_ALIASING,
|
||||||
|
R.string.renderer_anti_aliasing,
|
||||||
|
0,
|
||||||
|
R.array.rendererAntiAliasingNames,
|
||||||
|
R.array.rendererAntiAliasingValues,
|
||||||
|
IntSetting.RENDERER_ANTI_ALIASING.key,
|
||||||
|
IntSetting.RENDERER_ANTI_ALIASING.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_ASPECT_RATIO,
|
||||||
|
R.string.renderer_aspect_ratio,
|
||||||
|
0,
|
||||||
|
R.array.rendererAspectRatioNames,
|
||||||
|
R.array.rendererAspectRatioValues,
|
||||||
|
IntSetting.RENDERER_ASPECT_RATIO.key,
|
||||||
|
IntSetting.RENDERER_ASPECT_RATIO.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_USE_DISK_SHADER_CACHE,
|
||||||
|
R.string.use_disk_shader_cache,
|
||||||
|
R.string.use_disk_shader_cache_description,
|
||||||
|
IntSetting.RENDERER_USE_DISK_SHADER_CACHE.key,
|
||||||
|
IntSetting.RENDERER_USE_DISK_SHADER_CACHE.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_FORCE_MAX_CLOCK,
|
||||||
|
R.string.renderer_force_max_clock,
|
||||||
|
R.string.renderer_force_max_clock_description,
|
||||||
|
IntSetting.RENDERER_FORCE_MAX_CLOCK.key,
|
||||||
|
IntSetting.RENDERER_FORCE_MAX_CLOCK.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS,
|
||||||
|
R.string.renderer_asynchronous_shaders,
|
||||||
|
R.string.renderer_asynchronous_shaders_description,
|
||||||
|
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.key,
|
||||||
|
IntSetting.RENDERER_ASYNCHRONOUS_SHADERS.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addAudioSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_audio))
|
||||||
|
sl.add(
|
||||||
|
SliderSetting(
|
||||||
|
IntSetting.AUDIO_VOLUME,
|
||||||
|
R.string.audio_volume,
|
||||||
|
R.string.audio_volume_description,
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
"%",
|
||||||
|
IntSetting.AUDIO_VOLUME.key,
|
||||||
|
IntSetting.AUDIO_VOLUME.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addThemeSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_theme))
|
||||||
|
sl.apply {
|
||||||
|
val theme: AbstractIntSetting = object : AbstractIntSetting {
|
||||||
|
override var int: Int
|
||||||
|
get() = preferences.getInt(Settings.PREF_THEME, 0)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_THEME, value)
|
||||||
|
.apply()
|
||||||
|
settingsActivity.recreate()
|
||||||
|
}
|
||||||
|
override val key: String? = null
|
||||||
|
override val section: String? = null
|
||||||
|
override val isRuntimeEditable: Boolean = false
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = preferences.getInt(Settings.PREF_THEME, 0).toString()
|
||||||
|
override val defaultValue: Any = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
theme,
|
||||||
|
R.string.change_app_theme,
|
||||||
|
0,
|
||||||
|
R.array.themeEntriesA12,
|
||||||
|
R.array.themeValuesA12
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
theme,
|
||||||
|
R.string.change_app_theme,
|
||||||
|
0,
|
||||||
|
R.array.themeEntries,
|
||||||
|
R.array.themeValues
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val themeMode: AbstractIntSetting = object : AbstractIntSetting {
|
||||||
|
override var int: Int
|
||||||
|
get() = preferences.getInt(Settings.PREF_THEME_MODE, -1)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_THEME_MODE, value)
|
||||||
|
.apply()
|
||||||
|
ThemeHelper.setThemeMode(settingsActivity)
|
||||||
|
}
|
||||||
|
override val key: String? = null
|
||||||
|
override val section: String? = null
|
||||||
|
override val isRuntimeEditable: Boolean = false
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = preferences.getInt(Settings.PREF_THEME_MODE, -1).toString()
|
||||||
|
override val defaultValue: Any = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
themeMode,
|
||||||
|
R.string.change_theme_mode,
|
||||||
|
0,
|
||||||
|
R.array.themeModeEntries,
|
||||||
|
R.array.themeModeValues
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val blackBackgrounds: AbstractBooleanSetting = object : AbstractBooleanSetting {
|
||||||
|
override var boolean: Boolean
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean(Settings.PREF_BLACK_BACKGROUNDS, value)
|
||||||
|
.apply()
|
||||||
|
settingsActivity.recreate()
|
||||||
|
}
|
||||||
|
override val key: String? = null
|
||||||
|
override val section: String? = null
|
||||||
|
override val isRuntimeEditable: Boolean = false
|
||||||
|
override val valueAsString: String
|
||||||
|
get() = preferences.getBoolean(Settings.PREF_BLACK_BACKGROUNDS, false)
|
||||||
|
.toString()
|
||||||
|
override val defaultValue: Any = false
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
blackBackgrounds,
|
||||||
|
R.string.use_black_backgrounds,
|
||||||
|
R.string.use_black_backgrounds_description
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addDebugSettings(sl: ArrayList<SettingsItem>) {
|
||||||
|
settingsActivity.setToolbarTitle(settingsActivity.getString(R.string.preferences_debug))
|
||||||
|
sl.apply {
|
||||||
|
add(
|
||||||
|
SingleChoiceSetting(
|
||||||
|
IntSetting.RENDERER_BACKEND,
|
||||||
|
R.string.renderer_api,
|
||||||
|
0,
|
||||||
|
R.array.rendererApiNames,
|
||||||
|
R.array.rendererApiValues,
|
||||||
|
IntSetting.RENDERER_BACKEND.key,
|
||||||
|
IntSetting.RENDERER_BACKEND.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SwitchSetting(
|
||||||
|
IntSetting.RENDERER_DEBUG,
|
||||||
|
R.string.renderer_debug,
|
||||||
|
R.string.renderer_debug_description,
|
||||||
|
IntSetting.RENDERER_DEBUG.key,
|
||||||
|
IntSetting.RENDERER_DEBUG.defaultValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.AbstractSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction for a screen showing a list of settings. Instances of
|
||||||
|
* this type of view will each display a layer of the setting hierarchy.
|
||||||
|
*/
|
||||||
|
interface SettingsFragmentView {
|
||||||
|
/**
|
||||||
|
* Pass an ArrayList to the View so that it can be displayed on screen.
|
||||||
|
*
|
||||||
|
* @param settingsList The result of converting the HashMap to an ArrayList
|
||||||
|
*/
|
||||||
|
fun showSettingsList(settingsList: ArrayList<SettingsItem>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instructs the Fragment to load the settings screen.
|
||||||
|
*/
|
||||||
|
fun loadSettingsList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The Fragment's containing activity.
|
||||||
|
*/
|
||||||
|
val activityView: SettingsActivityView?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the Fragment to tell the containing Activity to show a new
|
||||||
|
* Fragment containing a submenu of settings.
|
||||||
|
*
|
||||||
|
* @param menuKey Identifier for the settings group that should be shown.
|
||||||
|
*/
|
||||||
|
fun loadSubMenu(menuKey: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the Fragment to tell the containing activity to display a toast message.
|
||||||
|
*
|
||||||
|
* @param message Text to be shown in the Toast
|
||||||
|
* @param is_long Whether this should be a long Toast or short one.
|
||||||
|
*/
|
||||||
|
fun showToastMessage(message: String?, is_long: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have the fragment add a setting to the HashMap.
|
||||||
|
*
|
||||||
|
* @param setting The (possibly previously missing) new setting.
|
||||||
|
*/
|
||||||
|
fun putSetting(setting: AbstractSetting)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Have the fragment tell the containing Activity that a setting was modified.
|
||||||
|
*/
|
||||||
|
fun onSettingChanged()
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.DateTimeSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
|
||||||
|
class DateTimeViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
private lateinit var setting: DateTimeSetting
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
setting = item as DateTimeSetting
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
val epochTime = setting.value.toLong()
|
||||||
|
val instant = Instant.ofEpochMilli(epochTime * 1000)
|
||||||
|
val zonedTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"))
|
||||||
|
val dateFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||||
|
binding.textSettingDescription.text = dateFormatter.format(zonedTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
adapter.onDateTimeClick(setting, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingsHeaderBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class HeaderViewHolder(val binding: ListItemSettingsHeaderBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
binding.textHeaderName.setText(item.nameId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
// no-op
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.RunnableSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class RunnableViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
private lateinit var setting: RunnableSetting
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
setting = item as RunnableSetting
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.textSettingDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
if (!setting.isRuntimeRunnable && !NativeLibrary.isRunning()) {
|
||||||
|
setting.runnable.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
// no-op
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
abstract class SettingViewHolder(itemView: View, protected val adapter: SettingsAdapter) :
|
||||||
|
RecyclerView.ViewHolder(itemView), View.OnClickListener, View.OnLongClickListener {
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.setOnClickListener(this)
|
||||||
|
itemView.setOnLongClickListener(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the adapter to set this ViewHolder's child views to display the list item
|
||||||
|
* it must now represent.
|
||||||
|
*
|
||||||
|
* @param item The list item that should be represented by this ViewHolder.
|
||||||
|
*/
|
||||||
|
abstract fun bind(item: SettingsItem)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when this ViewHolder's view is clicked on. Implementations should usually pass
|
||||||
|
* this event up to the adapter.
|
||||||
|
*
|
||||||
|
* @param clicked The view that was clicked on.
|
||||||
|
*/
|
||||||
|
abstract override fun onClick(clicked: View)
|
||||||
|
|
||||||
|
abstract override fun onLongClick(clicked: View): Boolean
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SingleChoiceSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.StringSingleChoiceSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class SingleChoiceViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
private lateinit var setting: SettingsItem
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
setting = item
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
} else if (item is SingleChoiceSetting) {
|
||||||
|
val resMgr = binding.textSettingDescription.context.resources
|
||||||
|
val values = resMgr.getIntArray(item.valuesId)
|
||||||
|
for (i in values.indices) {
|
||||||
|
if (values[i] == item.selectedValue) {
|
||||||
|
binding.textSettingDescription.text = resMgr.getStringArray(item.choicesId)[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.textSettingDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
if (!setting.isEditable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting is SingleChoiceSetting) {
|
||||||
|
adapter.onSingleChoiceClick(
|
||||||
|
(setting as SingleChoiceSetting),
|
||||||
|
bindingAdapterPosition
|
||||||
|
)
|
||||||
|
} else if (setting is StringSingleChoiceSetting) {
|
||||||
|
adapter.onStringSingleChoiceClick(
|
||||||
|
(setting as StringSingleChoiceSetting),
|
||||||
|
bindingAdapterPosition
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SliderSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class SliderViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
private lateinit var setting: SliderSetting
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
setting = item as SliderSetting
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.textSettingDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
adapter.onSliderClick(setting, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SubmenuSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class SubmenuViewHolder(val binding: ListItemSettingBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
private lateinit var item: SubmenuSetting
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
this.item = item as SubmenuSetting
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.textSettingDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
adapter.onSubmenuClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
// no-op
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.ui.viewholder
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.CompoundButton
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ListItemSettingSwitchBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SwitchSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.view.SettingsItem
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsAdapter
|
||||||
|
|
||||||
|
class SwitchSettingViewHolder(val binding: ListItemSettingSwitchBinding, adapter: SettingsAdapter) :
|
||||||
|
SettingViewHolder(binding.root, adapter) {
|
||||||
|
|
||||||
|
private lateinit var setting: SwitchSetting
|
||||||
|
|
||||||
|
override fun bind(item: SettingsItem) {
|
||||||
|
setting = item as SwitchSetting
|
||||||
|
binding.textSettingName.setText(item.nameId)
|
||||||
|
if (item.descriptionId != 0) {
|
||||||
|
binding.textSettingDescription.setText(item.descriptionId)
|
||||||
|
binding.textSettingDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.textSettingDescription.text = ""
|
||||||
|
binding.textSettingDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
binding.switchWidget.isChecked = setting.isChecked
|
||||||
|
binding.switchWidget.setOnCheckedChangeListener { _: CompoundButton, _: Boolean ->
|
||||||
|
adapter.onBooleanClick(item, bindingAdapterPosition, binding.switchWidget.isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchWidget.isEnabled = setting.isEditable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(clicked: View) {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
binding.switchWidget.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLongClick(clicked: View): Boolean {
|
||||||
|
if (setting.isEditable) {
|
||||||
|
return adapter.onLongClick(setting.setting!!, bindingAdapterPosition)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,241 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.features.settings.utils
|
||||||
|
|
||||||
|
import org.ini4j.Wini
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.*
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings.SettingsSectionMap
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivityView
|
||||||
|
import org.yuzu.yuzu_emu.utils.BiMap
|
||||||
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains static methods for interacting with .ini files in which settings are stored.
|
||||||
|
*/
|
||||||
|
object SettingsFile {
|
||||||
|
const val FILE_NAME_CONFIG = "config"
|
||||||
|
|
||||||
|
private var sectionsMap = BiMap<String?, String?>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves
|
||||||
|
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
|
||||||
|
* failed.
|
||||||
|
*
|
||||||
|
* @param ini The ini file to load the settings from
|
||||||
|
* @param isCustomGame
|
||||||
|
* @param view The current view.
|
||||||
|
* @return An Observable that emits a HashMap of the file's contents, then completes.
|
||||||
|
*/
|
||||||
|
private fun readFile(
|
||||||
|
ini: File?,
|
||||||
|
isCustomGame: Boolean,
|
||||||
|
view: SettingsActivityView? = null
|
||||||
|
): HashMap<String, SettingSection?> {
|
||||||
|
val sections: HashMap<String, SettingSection?> = SettingsSectionMap()
|
||||||
|
var reader: BufferedReader? = null
|
||||||
|
try {
|
||||||
|
reader = BufferedReader(FileReader(ini))
|
||||||
|
var current: SettingSection? = null
|
||||||
|
var line: String?
|
||||||
|
while (reader.readLine().also { line = it } != null) {
|
||||||
|
if (line!!.startsWith("[") && line!!.endsWith("]")) {
|
||||||
|
current = sectionFromLine(line!!, isCustomGame)
|
||||||
|
sections[current.name] = current
|
||||||
|
} else if (current != null) {
|
||||||
|
val setting = settingFromLine(line!!)
|
||||||
|
if (setting != null) {
|
||||||
|
current.putSetting(setting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Log.error("[SettingsFile] File not found: " + e.message)
|
||||||
|
view?.onSettingsFileNotFound()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.error("[SettingsFile] Error reading from: " + e.message)
|
||||||
|
view?.onSettingsFileNotFound()
|
||||||
|
} finally {
|
||||||
|
if (reader != null) {
|
||||||
|
try {
|
||||||
|
reader.close()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.error("[SettingsFile] Error closing: " + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readFile(fileName: String, view: SettingsActivityView?): HashMap<String, SettingSection?> {
|
||||||
|
return readFile(getSettingsFile(fileName), false, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readFile(fileName: String): HashMap<String, SettingSection?> =
|
||||||
|
readFile(getSettingsFile(fileName), false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves
|
||||||
|
* effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it
|
||||||
|
* failed.
|
||||||
|
*
|
||||||
|
* @param gameId the id of the game to load it's settings.
|
||||||
|
* @param view The current view.
|
||||||
|
*/
|
||||||
|
fun readCustomGameSettings(
|
||||||
|
gameId: String,
|
||||||
|
view: SettingsActivityView?
|
||||||
|
): HashMap<String, SettingSection?> {
|
||||||
|
return readFile(getCustomGameSettingsFile(gameId), true, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error
|
||||||
|
* telling why it failed.
|
||||||
|
*
|
||||||
|
* @param fileName The target filename without a path or extension.
|
||||||
|
* @param sections The HashMap containing the Settings we want to serialize.
|
||||||
|
* @param view The current view.
|
||||||
|
*/
|
||||||
|
fun saveFile(
|
||||||
|
fileName: String,
|
||||||
|
sections: TreeMap<String, SettingSection>,
|
||||||
|
view: SettingsActivityView
|
||||||
|
) {
|
||||||
|
val ini = getSettingsFile(fileName)
|
||||||
|
try {
|
||||||
|
val writer = Wini(ini)
|
||||||
|
val keySet: Set<String> = sections.keys
|
||||||
|
for (key in keySet) {
|
||||||
|
val section = sections[key]
|
||||||
|
writeSection(writer, section!!)
|
||||||
|
}
|
||||||
|
writer.store()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.message)
|
||||||
|
view.showToastMessage(
|
||||||
|
YuzuApplication.appContext
|
||||||
|
.getString(R.string.error_saving, fileName, e.message),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveCustomGameSettings(gameId: String?, sections: HashMap<String, SettingSection?>) {
|
||||||
|
val sortedSections: Set<String> = TreeSet(sections.keys)
|
||||||
|
for (sectionKey in sortedSections) {
|
||||||
|
val section = sections[sectionKey]
|
||||||
|
val settings = section!!.settings
|
||||||
|
val sortedKeySet: Set<String> = TreeSet(settings.keys)
|
||||||
|
for (settingKey in sortedKeySet) {
|
||||||
|
val setting = settings[settingKey]
|
||||||
|
NativeLibrary.setUserSetting(
|
||||||
|
gameId, mapSectionNameFromIni(
|
||||||
|
section.name
|
||||||
|
), setting!!.key, setting.valueAsString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapSectionNameFromIni(generalSectionName: String): String? {
|
||||||
|
return if (sectionsMap.getForward(generalSectionName) != null) {
|
||||||
|
sectionsMap.getForward(generalSectionName)
|
||||||
|
} else generalSectionName
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapSectionNameToIni(generalSectionName: String): String {
|
||||||
|
return if (sectionsMap.getBackward(generalSectionName) != null) {
|
||||||
|
sectionsMap.getBackward(generalSectionName).toString()
|
||||||
|
} else generalSectionName
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSettingsFile(fileName: String): File {
|
||||||
|
return File(
|
||||||
|
DirectoryInitialization.userDirectory + "/config/" + fileName + ".ini"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCustomGameSettingsFile(gameId: String): File {
|
||||||
|
return File(DirectoryInitialization.userDirectory + "/GameSettings/" + gameId + ".ini")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionFromLine(line: String, isCustomGame: Boolean): SettingSection {
|
||||||
|
var sectionName: String = line.substring(1, line.length - 1)
|
||||||
|
if (isCustomGame) {
|
||||||
|
sectionName = mapSectionNameToIni(sectionName)
|
||||||
|
}
|
||||||
|
return SettingSection(sectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a line of text, determines what type of data is being represented, and returns
|
||||||
|
* a Setting object containing this data.
|
||||||
|
*
|
||||||
|
* @param line The line of text being parsed.
|
||||||
|
* @return A typed Setting containing the key/value contained in the line.
|
||||||
|
*/
|
||||||
|
private fun settingFromLine(line: String): AbstractSetting? {
|
||||||
|
val splitLine = line.split("=".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
|
if (splitLine.size != 2) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val key = splitLine[0].trim { it <= ' ' }
|
||||||
|
val value = splitLine[1].trim { it <= ' ' }
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val booleanSetting = BooleanSetting.from(key)
|
||||||
|
if (booleanSetting != null) {
|
||||||
|
booleanSetting.boolean = value.toBoolean()
|
||||||
|
return booleanSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
val intSetting = IntSetting.from(key)
|
||||||
|
if (intSetting != null) {
|
||||||
|
intSetting.int = value.toInt()
|
||||||
|
return intSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
val floatSetting = FloatSetting.from(key)
|
||||||
|
if (floatSetting != null) {
|
||||||
|
floatSetting.float = value.toFloat()
|
||||||
|
return floatSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
val stringSetting = StringSetting.from(key)
|
||||||
|
if (stringSetting != null) {
|
||||||
|
stringSetting.string = value
|
||||||
|
return stringSetting
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the contents of a Section HashMap to disk.
|
||||||
|
*
|
||||||
|
* @param parser A Wini pointed at a file on disk.
|
||||||
|
* @param section A section containing settings to be written to the file.
|
||||||
|
*/
|
||||||
|
private fun writeSection(parser: Wini, section: SettingSection) {
|
||||||
|
// Write the section header.
|
||||||
|
val header = section.name
|
||||||
|
|
||||||
|
// Write this section's values.
|
||||||
|
val settings = section.settings
|
||||||
|
val keySet: Set<String> = settings.keys
|
||||||
|
for (key in keySet) {
|
||||||
|
val setting = settings[key]
|
||||||
|
parser.put(header, setting!!.key, setting.valueAsString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,125 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
|
import org.yuzu.yuzu_emu.BuildConfig
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentAboutBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
|
||||||
|
class AboutFragment : Fragment() {
|
||||||
|
private var _binding: FragmentAboutBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentAboutBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = false, animated = true)
|
||||||
|
homeViewModel.setStatusBarShadeVisibility(visible = false)
|
||||||
|
|
||||||
|
binding.toolbarAbout.setNavigationOnClickListener {
|
||||||
|
binding.root.findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.imageLogo.setOnLongClickListener {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
R.string.gaia_is_not_real,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.buttonContributors.setOnClickListener { openLink(getString(R.string.contributors_link)) }
|
||||||
|
binding.buttonLicenses.setOnClickListener {
|
||||||
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
binding.root.findNavController().navigate(R.id.action_aboutFragment_to_licensesFragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.textBuildHash.text = BuildConfig.GIT_HASH
|
||||||
|
binding.buttonBuildHash.setOnClickListener {
|
||||||
|
val clipBoard =
|
||||||
|
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val clip = ClipData.newPlainText(getString(R.string.build), BuildConfig.GIT_HASH)
|
||||||
|
clipBoard.setPrimaryClip(clip)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
R.string.copied_to_clipboard,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.buttonDiscord.setOnClickListener { openLink(getString(R.string.support_link)) }
|
||||||
|
binding.buttonWebsite.setOnClickListener { openLink(getString(R.string.website_link)) }
|
||||||
|
binding.buttonGithub.setOnClickListener { openLink(getString(R.string.github_link)) }
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLink(link: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
|
||||||
|
val leftInsets = barInsets.left + cutoutInsets.left
|
||||||
|
val rightInsets = barInsets.right + cutoutInsets.right
|
||||||
|
|
||||||
|
val mlpAppBar = binding.appbarAbout.layoutParams as MarginLayoutParams
|
||||||
|
mlpAppBar.leftMargin = leftInsets
|
||||||
|
mlpAppBar.rightMargin = rightInsets
|
||||||
|
binding.appbarAbout.layoutParams = mlpAppBar
|
||||||
|
|
||||||
|
val mlpScrollAbout = binding.scrollAbout.layoutParams as MarginLayoutParams
|
||||||
|
mlpScrollAbout.leftMargin = leftInsets
|
||||||
|
mlpScrollAbout.rightMargin = rightInsets
|
||||||
|
binding.scrollAbout.layoutParams = mlpScrollAbout
|
||||||
|
|
||||||
|
binding.contentAbout.updatePadding(bottom = barInsets.bottom)
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentEarlyAccessBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
|
||||||
|
class EarlyAccessFragment : Fragment() {
|
||||||
|
private var _binding: FragmentEarlyAccessBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentEarlyAccessBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = false, animated = true)
|
||||||
|
homeViewModel.setStatusBarShadeVisibility(visible = false)
|
||||||
|
|
||||||
|
binding.toolbarAbout.setNavigationOnClickListener {
|
||||||
|
parentFragmentManager.primaryNavigationFragment?.findNavController()?.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.getEarlyAccessButton.setOnClickListener { openLink(getString(R.string.play_store_link)) }
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLink(link: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
|
||||||
|
val leftInsets = barInsets.left + cutoutInsets.left
|
||||||
|
val rightInsets = barInsets.right + cutoutInsets.right
|
||||||
|
|
||||||
|
val mlpAppBar = binding.appbarEa.layoutParams as ViewGroup.MarginLayoutParams
|
||||||
|
mlpAppBar.leftMargin = leftInsets
|
||||||
|
mlpAppBar.rightMargin = rightInsets
|
||||||
|
binding.appbarEa.layoutParams = mlpAppBar
|
||||||
|
|
||||||
|
binding.scrollEa.updatePadding(
|
||||||
|
left = leftInsets,
|
||||||
|
right = rightInsets,
|
||||||
|
bottom = barInsets.bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,613 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Rational
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.window.layout.FoldingFeature
|
||||||
|
import androidx.window.layout.WindowLayoutInfo
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.slider.Slider
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogOverlayAdjustBinding
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentEmulationBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import org.yuzu.yuzu_emu.utils.*
|
||||||
|
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
|
||||||
|
|
||||||
|
class EmulationFragment : Fragment(), SurfaceHolder.Callback {
|
||||||
|
private lateinit var preferences: SharedPreferences
|
||||||
|
private lateinit var emulationState: EmulationState
|
||||||
|
private var emulationActivity: EmulationActivity? = null
|
||||||
|
private var perfStatsUpdater: (() -> Unit)? = null
|
||||||
|
|
||||||
|
private var _binding: FragmentEmulationBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var game: Game
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
if (context is EmulationActivity) {
|
||||||
|
emulationActivity = context
|
||||||
|
NativeLibrary.setEmulationActivity(context)
|
||||||
|
} else {
|
||||||
|
throw IllegalStateException("EmulationFragment must have EmulationActivity parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize anything that doesn't depend on the layout / views in here.
|
||||||
|
*/
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// So this fragment doesn't restart on configuration changes; i.e. rotation.
|
||||||
|
retainInstance = true
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
game = requireArguments().parcelable(EmulationActivity.EXTRA_SELECTED_GAME)!!
|
||||||
|
emulationState = EmulationState(game.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the UI and start emulation in here.
|
||||||
|
*/
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentEmulationBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding.surfaceEmulation.holder.addCallback(this)
|
||||||
|
binding.showFpsText.setTextColor(Color.YELLOW)
|
||||||
|
binding.doneControlConfig.setOnClickListener { stopConfiguringControls() }
|
||||||
|
|
||||||
|
// Setup overlay.
|
||||||
|
updateShowFpsOverlay()
|
||||||
|
|
||||||
|
binding.inGameMenu.getHeaderView(0).findViewById<TextView>(R.id.text_game_title).text =
|
||||||
|
game.title
|
||||||
|
binding.inGameMenu.setNavigationItemSelectedListener {
|
||||||
|
when (it.itemId) {
|
||||||
|
R.id.menu_pause_emulation -> {
|
||||||
|
if (emulationState.isPaused) {
|
||||||
|
emulationState.run(false)
|
||||||
|
it.title = resources.getString(R.string.emulation_pause)
|
||||||
|
it.icon = ResourcesCompat.getDrawable(
|
||||||
|
resources,
|
||||||
|
R.drawable.ic_pause,
|
||||||
|
requireContext().theme
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
emulationState.pause()
|
||||||
|
it.title = resources.getString(R.string.emulation_unpause)
|
||||||
|
it.icon = ResourcesCompat.getDrawable(
|
||||||
|
resources,
|
||||||
|
R.drawable.ic_play,
|
||||||
|
requireContext().theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_settings -> {
|
||||||
|
SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_overlay_controls -> {
|
||||||
|
showOverlayOptions()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_exit -> {
|
||||||
|
emulationState.stop()
|
||||||
|
requireActivity().finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(
|
||||||
|
requireActivity(),
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (binding.drawerLayout.isOpen) binding.drawerLayout.close() else binding.drawerLayout.open()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (!DirectoryInitialization.areDirectoriesReady) {
|
||||||
|
DirectoryInitialization.start(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.surfaceEmulation.setAspectRatio(
|
||||||
|
when (IntSetting.RENDERER_ASPECT_RATIO.int) {
|
||||||
|
0 -> Rational(16, 9)
|
||||||
|
1 -> Rational(4, 3)
|
||||||
|
2 -> Rational(21, 9)
|
||||||
|
3 -> Rational(16, 10)
|
||||||
|
4 -> null // Stretch
|
||||||
|
else -> Rational(16, 9)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
emulationState.run(emulationActivity!!.isActivityRecreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
if (emulationState.isRunning) {
|
||||||
|
emulationState.pause()
|
||||||
|
}
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetach() {
|
||||||
|
NativeLibrary.clearEmulationActivity()
|
||||||
|
super.onDetach()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshInputOverlay() {
|
||||||
|
binding.surfaceInputOverlay.refreshControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetInputOverlay() {
|
||||||
|
preferences.edit()
|
||||||
|
.remove(Settings.PREF_CONTROL_SCALE)
|
||||||
|
.remove(Settings.PREF_CONTROL_OPACITY)
|
||||||
|
.apply()
|
||||||
|
binding.surfaceInputOverlay.post { binding.surfaceInputOverlay.resetButtonPlacement() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateShowFpsOverlay() {
|
||||||
|
if (EmulationMenuSettings.showFps) {
|
||||||
|
val SYSTEM_FPS = 0
|
||||||
|
val FPS = 1
|
||||||
|
val FRAMETIME = 2
|
||||||
|
val SPEED = 3
|
||||||
|
perfStatsUpdater = {
|
||||||
|
val perfStats = NativeLibrary.getPerfStats()
|
||||||
|
if (perfStats[FPS] > 0 && _binding != null) {
|
||||||
|
binding.showFpsText.text = String.format("FPS: %.1f", perfStats[FPS])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emulationState.isStopped) {
|
||||||
|
perfStatsUpdateHandler.postDelayed(perfStatsUpdater!!, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
perfStatsUpdateHandler.post(perfStatsUpdater!!)
|
||||||
|
binding.showFpsText.text = resources.getString(R.string.emulation_game_loading)
|
||||||
|
binding.showFpsText.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
if (perfStatsUpdater != null) {
|
||||||
|
perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater!!)
|
||||||
|
}
|
||||||
|
binding.showFpsText.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val Number.toPx get() = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), Resources.getSystem().displayMetrics).toInt()
|
||||||
|
|
||||||
|
fun updateCurrentLayout(emulationActivity: EmulationActivity, newLayoutInfo: WindowLayoutInfo) {
|
||||||
|
val isFolding = (newLayoutInfo.displayFeatures.find { it is FoldingFeature } as? FoldingFeature)?.let {
|
||||||
|
if (it.isSeparating) {
|
||||||
|
emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
|
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL) {
|
||||||
|
binding.surfaceEmulation.layoutParams.height = it.bounds.top
|
||||||
|
binding.inGameMenu.layoutParams.height = it.bounds.bottom
|
||||||
|
binding.overlayContainer.layoutParams.height = it.bounds.bottom - 48.toPx
|
||||||
|
binding.overlayContainer.updatePadding(0, 0, 0, 24.toPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it.isSeparating
|
||||||
|
} ?: false
|
||||||
|
if (!isFolding) {
|
||||||
|
binding.surfaceEmulation.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
binding.inGameMenu.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
binding.overlayContainer.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
binding.overlayContainer.updatePadding(0, 0, 0, 0)
|
||||||
|
emulationActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||||
|
}
|
||||||
|
binding.surfaceInputOverlay.requestLayout()
|
||||||
|
binding.inGameMenu.requestLayout()
|
||||||
|
binding.overlayContainer.requestLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
|
// We purposely don't do anything here.
|
||||||
|
// All work is done in surfaceChanged, which we are guaranteed to get even for surface creation.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
|
Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height)
|
||||||
|
emulationState.newSurface(holder.surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
|
emulationState.clearSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showOverlayOptions() {
|
||||||
|
val anchor = binding.inGameMenu.findViewById<View>(R.id.menu_overlay_controls)
|
||||||
|
val popup = PopupMenu(requireContext(), anchor)
|
||||||
|
|
||||||
|
popup.menuInflater.inflate(R.menu.menu_overlay_options, popup.menu)
|
||||||
|
|
||||||
|
popup.menu.apply {
|
||||||
|
findItem(R.id.menu_toggle_fps).isChecked = EmulationMenuSettings.showFps
|
||||||
|
findItem(R.id.menu_rel_stick_center).isChecked = EmulationMenuSettings.joystickRelCenter
|
||||||
|
findItem(R.id.menu_dpad_slide).isChecked = EmulationMenuSettings.dpadSlide
|
||||||
|
findItem(R.id.menu_show_overlay).isChecked = EmulationMenuSettings.showOverlay
|
||||||
|
findItem(R.id.menu_haptics).isChecked = EmulationMenuSettings.hapticFeedback
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.setOnMenuItemClickListener {
|
||||||
|
when (it.itemId) {
|
||||||
|
R.id.menu_toggle_fps -> {
|
||||||
|
it.isChecked = !it.isChecked
|
||||||
|
EmulationMenuSettings.showFps = it.isChecked
|
||||||
|
updateShowFpsOverlay()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_edit_overlay -> {
|
||||||
|
binding.drawerLayout.close()
|
||||||
|
binding.surfaceInputOverlay.requestFocus()
|
||||||
|
startConfiguringControls()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_adjust_overlay -> {
|
||||||
|
adjustOverlay()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_toggle_controls -> {
|
||||||
|
val preferences =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
val optionsArray = BooleanArray(15)
|
||||||
|
for (i in 0..14) {
|
||||||
|
optionsArray[i] = preferences.getBoolean("buttonToggle$i", i < 13)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.emulation_toggle_controls)
|
||||||
|
.setMultiChoiceItems(
|
||||||
|
R.array.gamepadButtons,
|
||||||
|
optionsArray
|
||||||
|
) { _, indexSelected, isChecked ->
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean("buttonToggle$indexSelected", isChecked)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
refreshInputOverlay()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setNeutralButton(R.string.emulation_toggle_all) { _, _ -> }
|
||||||
|
.show()
|
||||||
|
|
||||||
|
// Override normal behaviour so the dialog doesn't close
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
|
||||||
|
.setOnClickListener {
|
||||||
|
val isChecked = !optionsArray[0]
|
||||||
|
for (i in 0..14) {
|
||||||
|
optionsArray[i] = isChecked
|
||||||
|
dialog.listView.setItemChecked(i, isChecked)
|
||||||
|
preferences.edit()
|
||||||
|
.putBoolean("buttonToggle$i", isChecked)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_show_overlay -> {
|
||||||
|
it.isChecked = !it.isChecked
|
||||||
|
EmulationMenuSettings.showOverlay = it.isChecked
|
||||||
|
refreshInputOverlay()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_rel_stick_center -> {
|
||||||
|
it.isChecked = !it.isChecked
|
||||||
|
EmulationMenuSettings.joystickRelCenter = it.isChecked
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_dpad_slide -> {
|
||||||
|
it.isChecked = !it.isChecked
|
||||||
|
EmulationMenuSettings.dpadSlide = it.isChecked
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_haptics -> {
|
||||||
|
it.isChecked = !it.isChecked
|
||||||
|
EmulationMenuSettings.hapticFeedback = it.isChecked
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.menu_reset_overlay -> {
|
||||||
|
binding.drawerLayout.close()
|
||||||
|
resetInputOverlay()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popup.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startConfiguringControls() {
|
||||||
|
binding.doneControlConfig.visibility = View.VISIBLE
|
||||||
|
binding.surfaceInputOverlay.setIsInEditMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopConfiguringControls() {
|
||||||
|
binding.doneControlConfig.visibility = View.GONE
|
||||||
|
binding.surfaceInputOverlay.setIsInEditMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
private fun adjustOverlay() {
|
||||||
|
val adjustBinding = DialogOverlayAdjustBinding.inflate(layoutInflater)
|
||||||
|
adjustBinding.apply {
|
||||||
|
inputScaleSlider.apply {
|
||||||
|
valueTo = 150F
|
||||||
|
value = preferences.getInt(Settings.PREF_CONTROL_SCALE, 50).toFloat()
|
||||||
|
addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
|
||||||
|
inputScaleValue.text = "${value.toInt()}%"
|
||||||
|
setControlScale(value.toInt())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
inputOpacitySlider.apply {
|
||||||
|
valueTo = 100F
|
||||||
|
value = preferences.getInt(Settings.PREF_CONTROL_OPACITY, 100).toFloat()
|
||||||
|
addOnChangeListener(Slider.OnChangeListener { _, value, _ ->
|
||||||
|
inputOpacityValue.text = "${value.toInt()}%"
|
||||||
|
setControlOpacity(value.toInt())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
inputScaleValue.text = "${inputScaleSlider.value.toInt()}%"
|
||||||
|
inputOpacityValue.text = "${inputOpacitySlider.value.toInt()}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.emulation_control_adjust)
|
||||||
|
.setView(adjustBinding.root)
|
||||||
|
.setPositiveButton(android.R.string.ok, null)
|
||||||
|
.setNeutralButton(R.string.slider_default) { _: DialogInterface?, _: Int ->
|
||||||
|
setControlScale(50)
|
||||||
|
setControlOpacity(100)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlScale(scale: Int) {
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_CONTROL_SCALE, scale)
|
||||||
|
.apply()
|
||||||
|
refreshInputOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setControlOpacity(opacity: Int) {
|
||||||
|
preferences.edit()
|
||||||
|
.putInt(Settings.PREF_CONTROL_OPACITY, opacity)
|
||||||
|
.apply()
|
||||||
|
refreshInputOverlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() {
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.inGameMenu) { v: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val cutInsets: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
var left = 0
|
||||||
|
var right = 0
|
||||||
|
if (ViewCompat.getLayoutDirection(v) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
left = cutInsets.left
|
||||||
|
} else {
|
||||||
|
right = cutInsets.right
|
||||||
|
}
|
||||||
|
|
||||||
|
v.setPadding(left, cutInsets.top, right, 0)
|
||||||
|
|
||||||
|
// Ensure FPS text doesn't get cut off by rounded display corners
|
||||||
|
val sidePadding = resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
|
||||||
|
if (cutInsets.left == 0) {
|
||||||
|
binding.showFpsText.setPadding(
|
||||||
|
sidePadding,
|
||||||
|
cutInsets.top,
|
||||||
|
cutInsets.right,
|
||||||
|
cutInsets.bottom
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
binding.showFpsText.setPadding(
|
||||||
|
cutInsets.left,
|
||||||
|
cutInsets.top,
|
||||||
|
cutInsets.right,
|
||||||
|
cutInsets.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EmulationState(private val gamePath: String) {
|
||||||
|
private var state: State
|
||||||
|
private var surface: Surface? = null
|
||||||
|
private var runWhenSurfaceIsValid = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Starting state is stopped.
|
||||||
|
state = State.STOPPED
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
val isStopped: Boolean
|
||||||
|
get() = state == State.STOPPED
|
||||||
|
|
||||||
|
// Getters for the current state
|
||||||
|
@get:Synchronized
|
||||||
|
val isPaused: Boolean
|
||||||
|
get() = state == State.PAUSED
|
||||||
|
|
||||||
|
@get:Synchronized
|
||||||
|
val isRunning: Boolean
|
||||||
|
get() = state == State.RUNNING
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun stop() {
|
||||||
|
if (state != State.STOPPED) {
|
||||||
|
Log.debug("[EmulationFragment] Stopping emulation.")
|
||||||
|
NativeLibrary.stopEmulation()
|
||||||
|
state = State.STOPPED
|
||||||
|
} else {
|
||||||
|
Log.warning("[EmulationFragment] Stop called while already stopped.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State changing methods
|
||||||
|
@Synchronized
|
||||||
|
fun pause() {
|
||||||
|
if (state != State.PAUSED) {
|
||||||
|
Log.debug("[EmulationFragment] Pausing emulation.")
|
||||||
|
|
||||||
|
NativeLibrary.pauseEmulation()
|
||||||
|
|
||||||
|
state = State.PAUSED
|
||||||
|
} else {
|
||||||
|
Log.warning("[EmulationFragment] Pause called while already paused.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun run(isActivityRecreated: Boolean) {
|
||||||
|
if (isActivityRecreated) {
|
||||||
|
if (NativeLibrary.isRunning()) {
|
||||||
|
state = State.PAUSED
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.debug("[EmulationFragment] activity resumed or fresh start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the surface is set, run now. Otherwise, wait for it to get set.
|
||||||
|
if (surface != null) {
|
||||||
|
runWithValidSurface()
|
||||||
|
} else {
|
||||||
|
runWhenSurfaceIsValid = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface callbacks
|
||||||
|
@Synchronized
|
||||||
|
fun newSurface(surface: Surface?) {
|
||||||
|
this.surface = surface
|
||||||
|
if (runWhenSurfaceIsValid) {
|
||||||
|
runWithValidSurface()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clearSurface() {
|
||||||
|
if (surface == null) {
|
||||||
|
Log.warning("[EmulationFragment] clearSurface called, but surface already null.")
|
||||||
|
} else {
|
||||||
|
surface = null
|
||||||
|
Log.debug("[EmulationFragment] Surface destroyed.")
|
||||||
|
when (state) {
|
||||||
|
State.RUNNING -> {
|
||||||
|
state = State.PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
|
State.PAUSED -> Log.warning("[EmulationFragment] Surface cleared while emulation paused.")
|
||||||
|
else -> Log.warning("[EmulationFragment] Surface cleared while emulation stopped.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runWithValidSurface() {
|
||||||
|
runWhenSurfaceIsValid = false
|
||||||
|
when (state) {
|
||||||
|
State.STOPPED -> {
|
||||||
|
NativeLibrary.surfaceChanged(surface)
|
||||||
|
val emulationThread = Thread({
|
||||||
|
Log.debug("[EmulationFragment] Starting emulation thread.")
|
||||||
|
NativeLibrary.run(gamePath)
|
||||||
|
}, "NativeEmulation")
|
||||||
|
emulationThread.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
State.PAUSED -> {
|
||||||
|
Log.debug("[EmulationFragment] Resuming emulation.")
|
||||||
|
NativeLibrary.surfaceChanged(surface)
|
||||||
|
NativeLibrary.unPauseEmulation()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Log.debug("[EmulationFragment] Bug, run called while already running.")
|
||||||
|
}
|
||||||
|
state = State.RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class State {
|
||||||
|
STOPPED, RUNNING, PAUSED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val perfStatsUpdateHandler = Handler(Looper.myLooper()!!)
|
||||||
|
|
||||||
|
fun newInstance(game: Game): EmulationFragment {
|
||||||
|
val args = Bundle()
|
||||||
|
args.putParcelable(EmulationActivity.EXTRA_SELECTED_GAME, game)
|
||||||
|
val fragment = EmulationFragment()
|
||||||
|
fragment.arguments = args
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,330 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
|
import org.yuzu.yuzu_emu.BuildConfig
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.adapters.HomeSettingAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentHomeSettingsBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.DocumentProvider
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeSetting
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.ui.main.MainActivity
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil
|
||||||
|
import org.yuzu.yuzu_emu.utils.GpuDriverHelper
|
||||||
|
|
||||||
|
class HomeSettingsFragment : Fragment() {
|
||||||
|
private var _binding: FragmentHomeSettingsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var mainActivity: MainActivity
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentHomeSettingsBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
mainActivity = requireActivity() as MainActivity
|
||||||
|
|
||||||
|
val optionsList: MutableList<HomeSetting> = mutableListOf(
|
||||||
|
HomeSetting(
|
||||||
|
R.string.advanced_settings,
|
||||||
|
R.string.settings_description,
|
||||||
|
R.drawable.ic_settings
|
||||||
|
) { SettingsActivity.launch(requireContext(), SettingsFile.FILE_NAME_CONFIG, "") },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.open_user_folder,
|
||||||
|
R.string.open_user_folder_description,
|
||||||
|
R.drawable.ic_folder_open
|
||||||
|
) { openFileManager() },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.preferences_theme,
|
||||||
|
R.string.theme_and_color_description,
|
||||||
|
R.drawable.ic_palette
|
||||||
|
) { SettingsActivity.launch(requireContext(), Settings.SECTION_THEME, "") },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.install_gpu_driver,
|
||||||
|
R.string.install_gpu_driver_description,
|
||||||
|
R.drawable.ic_exit
|
||||||
|
) { driverInstaller() },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.install_amiibo_keys,
|
||||||
|
R.string.install_amiibo_keys_description,
|
||||||
|
R.drawable.ic_nfc
|
||||||
|
) { mainActivity.getAmiiboKey.launch(arrayOf("*/*")) },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.select_games_folder,
|
||||||
|
R.string.select_games_folder_description,
|
||||||
|
R.drawable.ic_add
|
||||||
|
) { mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.manage_save_data,
|
||||||
|
R.string.import_export_saves_description,
|
||||||
|
R.drawable.ic_save
|
||||||
|
) { ImportExportSavesFragment().show(parentFragmentManager, ImportExportSavesFragment.TAG) },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.install_prod_keys,
|
||||||
|
R.string.install_prod_keys_description,
|
||||||
|
R.drawable.ic_unlock
|
||||||
|
) { mainActivity.getProdKey.launch(arrayOf("*/*")) },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.install_firmware,
|
||||||
|
R.string.install_firmware_description,
|
||||||
|
R.drawable.ic_firmware
|
||||||
|
) { mainActivity.getFirmware.launch(arrayOf("application/zip")) },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.share_log,
|
||||||
|
R.string.share_log_description,
|
||||||
|
R.drawable.ic_log
|
||||||
|
) { shareLog() },
|
||||||
|
HomeSetting(
|
||||||
|
R.string.about,
|
||||||
|
R.string.about_description,
|
||||||
|
R.drawable.ic_info_outline
|
||||||
|
) {
|
||||||
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
parentFragmentManager.primaryNavigationFragment?.findNavController()
|
||||||
|
?.navigate(R.id.action_homeSettingsFragment_to_aboutFragment)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!BuildConfig.PREMIUM) {
|
||||||
|
optionsList.add(
|
||||||
|
0,
|
||||||
|
HomeSetting(
|
||||||
|
R.string.get_early_access,
|
||||||
|
R.string.get_early_access_description,
|
||||||
|
R.drawable.ic_diamond
|
||||||
|
) {
|
||||||
|
exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
parentFragmentManager.primaryNavigationFragment?.findNavController()
|
||||||
|
?.navigate(R.id.action_homeSettingsFragment_to_earlyAccessFragment)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.homeSettingsList.apply {
|
||||||
|
layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
adapter = HomeSettingAdapter(requireActivity() as AppCompatActivity, optionsList)
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
exitTransition = null
|
||||||
|
homeViewModel.setNavigationVisibility(visible = true, animated = true)
|
||||||
|
homeViewModel.setStatusBarShadeVisibility(visible = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openFileManager() {
|
||||||
|
// First, try to open the user data folder directly
|
||||||
|
try {
|
||||||
|
startActivity(getFileManagerIntentOnDocumentProvider(Intent.ACTION_VIEW))
|
||||||
|
return
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startActivity(getFileManagerIntentOnDocumentProvider("android.provider.action.BROWSE"))
|
||||||
|
return
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just try to open the file manager, try the package name used on "normal" phones
|
||||||
|
try {
|
||||||
|
startActivity(getFileManagerIntent("com.google.android.documentsui"))
|
||||||
|
showNoLinkNotification()
|
||||||
|
return
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Next, try the AOSP package name
|
||||||
|
startActivity(getFileManagerIntent("com.android.documentsui"))
|
||||||
|
showNoLinkNotification()
|
||||||
|
return
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
resources.getString(R.string.no_file_manager),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileManagerIntent(packageName: String): Intent {
|
||||||
|
// Fragile, but some phones don't expose the system file manager in any better way
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN)
|
||||||
|
intent.setClassName(packageName, "com.android.documentsui.files.FilesActivity")
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileManagerIntentOnDocumentProvider(action: String): Intent {
|
||||||
|
val authority = "${requireContext().packageName}.user"
|
||||||
|
val intent = Intent(action)
|
||||||
|
intent.addCategory(Intent.CATEGORY_DEFAULT)
|
||||||
|
intent.data = DocumentsContract.buildRootUri(authority, DocumentProvider.ROOT_ID)
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNoLinkNotification() {
|
||||||
|
val builder = NotificationCompat.Builder(
|
||||||
|
requireContext(),
|
||||||
|
getString(R.string.notice_notification_channel_id)
|
||||||
|
)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_notification_logo)
|
||||||
|
.setContentTitle(getString(R.string.notification_no_directory_link))
|
||||||
|
.setContentText(getString(R.string.notification_no_directory_link_description))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
// TODO: Make the click action for this notification lead to a help article
|
||||||
|
|
||||||
|
with(NotificationManagerCompat.from(requireContext())) {
|
||||||
|
if (ActivityCompat.checkSelfPermission(
|
||||||
|
requireContext(),
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
resources.getString(R.string.notification_permission_not_granted),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(0, builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun driverInstaller() {
|
||||||
|
// Get the driver name for the dialog message.
|
||||||
|
var driverName = GpuDriverHelper.customDriverName
|
||||||
|
if (driverName == null) {
|
||||||
|
driverName = getString(R.string.system_gpu_driver)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(getString(R.string.select_gpu_driver_title))
|
||||||
|
.setMessage(driverName)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setNeutralButton(R.string.select_gpu_driver_default) { _: DialogInterface?, _: Int ->
|
||||||
|
GpuDriverHelper.installDefaultDriver(requireContext())
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
R.string.select_gpu_driver_use_default,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
.setPositiveButton(R.string.select_gpu_driver_install) { _: DialogInterface?, _: Int ->
|
||||||
|
mainActivity.getDriver.launch(arrayOf("application/zip"))
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareLog() {
|
||||||
|
val file = DocumentFile.fromSingleUri(
|
||||||
|
mainActivity,
|
||||||
|
DocumentsContract.buildDocumentUri(
|
||||||
|
DocumentProvider.AUTHORITY,
|
||||||
|
"${DocumentProvider.ROOT_ID}/log/yuzu_log.txt"
|
||||||
|
)
|
||||||
|
)!!
|
||||||
|
if (file.exists()) {
|
||||||
|
val intent = Intent(Intent.ACTION_SEND)
|
||||||
|
.setDataAndType(file.uri, FileUtil.TEXT_PLAIN)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||||
|
startActivity(Intent.createChooser(intent, getText(R.string.share_log)))
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
getText(R.string.share_log_missing),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||||
|
val spacingNavigationRail =
|
||||||
|
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||||
|
|
||||||
|
val leftInsets = barInsets.left + cutoutInsets.left
|
||||||
|
val rightInsets = barInsets.right + cutoutInsets.right
|
||||||
|
|
||||||
|
binding.scrollViewSettings.updatePadding(
|
||||||
|
top = barInsets.top,
|
||||||
|
bottom = barInsets.bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
val mlpScrollSettings = binding.scrollViewSettings.layoutParams as MarginLayoutParams
|
||||||
|
mlpScrollSettings.leftMargin = leftInsets
|
||||||
|
mlpScrollSettings.rightMargin = rightInsets
|
||||||
|
binding.scrollViewSettings.layoutParams = mlpScrollSettings
|
||||||
|
|
||||||
|
binding.linearLayoutSettings.updatePadding(bottom = spacingNavigation)
|
||||||
|
|
||||||
|
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
binding.linearLayoutSettings.updatePadding(left = spacingNavigationRail)
|
||||||
|
} else {
|
||||||
|
binding.linearLayoutSettings.updatePadding(right = spacingNavigationRail)
|
||||||
|
}
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,210 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.features.DocumentProvider
|
||||||
|
import org.yuzu.yuzu_emu.getPublicFilesDir
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.FilenameFilter
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class ImportExportSavesFragment : DialogFragment() {
|
||||||
|
private val context = YuzuApplication.appContext
|
||||||
|
private val savesFolder =
|
||||||
|
"${context.getPublicFilesDir().canonicalPath}/nand/user/save/0000000000000000"
|
||||||
|
|
||||||
|
// Get first subfolder in saves folder (should be the user folder)
|
||||||
|
private val savesFolderRoot = File(savesFolder).listFiles()?.firstOrNull()?.canonicalPath ?: ""
|
||||||
|
private var lastZipCreated: File? = null
|
||||||
|
|
||||||
|
private lateinit var startForResultExportSave: ActivityResultLauncher<Intent>
|
||||||
|
private lateinit var documentPicker: ActivityResultLauncher<Array<String>>
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val activity = requireActivity() as AppCompatActivity
|
||||||
|
|
||||||
|
val activityResultRegistry = requireActivity().activityResultRegistry
|
||||||
|
startForResultExportSave = activityResultRegistry.register(
|
||||||
|
"startForResultExportSaveKey",
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) {
|
||||||
|
File(context.getPublicFilesDir().canonicalPath, "temp").deleteRecursively()
|
||||||
|
}
|
||||||
|
documentPicker = activityResultRegistry.register(
|
||||||
|
"documentPickerKey",
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) {
|
||||||
|
it?.let { uri -> importSave(uri, activity) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return if (savesFolderRoot == "") {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.manage_save_data)
|
||||||
|
.setMessage(R.string.import_export_saves_no_profile)
|
||||||
|
.setPositiveButton(android.R.string.ok, null)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.manage_save_data)
|
||||||
|
.setMessage(R.string.manage_save_data_description)
|
||||||
|
.setNegativeButton(R.string.export_saves) { _, _ ->
|
||||||
|
exportSave()
|
||||||
|
}
|
||||||
|
.setPositiveButton(R.string.import_saves) { _, _ ->
|
||||||
|
documentPicker.launch(arrayOf("application/zip"))
|
||||||
|
}
|
||||||
|
.setNeutralButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zips the save files located in the given folder path and creates a new zip file with the current date and time.
|
||||||
|
* @return true if the zip file is successfully created, false otherwise.
|
||||||
|
*/
|
||||||
|
private fun zipSave(): Boolean {
|
||||||
|
try {
|
||||||
|
val tempFolder = File(requireContext().getPublicFilesDir().canonicalPath, "temp")
|
||||||
|
tempFolder.mkdirs()
|
||||||
|
val saveFolder = File(savesFolderRoot)
|
||||||
|
val outputZipFile = File(
|
||||||
|
tempFolder,
|
||||||
|
"yuzu saves - ${
|
||||||
|
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
|
||||||
|
}.zip"
|
||||||
|
)
|
||||||
|
outputZipFile.createNewFile()
|
||||||
|
ZipOutputStream(BufferedOutputStream(FileOutputStream(outputZipFile))).use { zos ->
|
||||||
|
saveFolder.walkTopDown().forEach { file ->
|
||||||
|
val zipFileName =
|
||||||
|
file.absolutePath.removePrefix(savesFolderRoot).removePrefix("/")
|
||||||
|
if (zipFileName == "")
|
||||||
|
return@forEach
|
||||||
|
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
|
||||||
|
zos.putNextEntry(entry)
|
||||||
|
if (file.isFile)
|
||||||
|
file.inputStream().use { fis -> fis.copyTo(zos) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastZipCreated = outputZipFile
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the save file located in the given folder path by creating a zip file and sharing it via intent.
|
||||||
|
*/
|
||||||
|
private fun exportSave() {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
val wasZipCreated = zipSave()
|
||||||
|
val lastZipFile = lastZipCreated
|
||||||
|
if (!wasZipCreated || lastZipFile == null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, "Failed to export save", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val file = DocumentFile.fromSingleUri(
|
||||||
|
context, DocumentsContract.buildDocumentUri(
|
||||||
|
DocumentProvider.AUTHORITY,
|
||||||
|
"${DocumentProvider.ROOT_ID}/temp/${lastZipFile.name}"
|
||||||
|
)
|
||||||
|
)!!
|
||||||
|
val intent = Intent(Intent.ACTION_SEND)
|
||||||
|
.setDataAndType(file.uri, "application/zip")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_STREAM, file.uri)
|
||||||
|
startForResultExportSave.launch(Intent.createChooser(intent, "Share save file"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the save files contained in the zip file, and replaces any existing ones with the new save file.
|
||||||
|
* @param zipUri The Uri of the zip file containing the save file(s) to import.
|
||||||
|
*/
|
||||||
|
private fun importSave(zipUri: Uri, activity: AppCompatActivity) {
|
||||||
|
val inputZip = context.contentResolver.openInputStream(zipUri)
|
||||||
|
// A zip needs to have at least one subfolder named after a TitleId in order to be considered valid.
|
||||||
|
var validZip = false
|
||||||
|
val savesFolder = File(savesFolderRoot)
|
||||||
|
val cacheSaveDir = File("${context.cacheDir.path}/saves/")
|
||||||
|
cacheSaveDir.mkdir()
|
||||||
|
|
||||||
|
if (inputZip == null) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val filterTitleId =
|
||||||
|
FilenameFilter { _, dirName -> dirName.matches(Regex("^0100[\\dA-Fa-f]{12}$")) }
|
||||||
|
|
||||||
|
try {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
FileUtil.unzip(inputZip, cacheSaveDir)
|
||||||
|
cacheSaveDir.list(filterTitleId)?.forEach { savePath ->
|
||||||
|
File(savesFolder, savePath).deleteRecursively()
|
||||||
|
File(cacheSaveDir, savePath).copyRecursively(File(savesFolder, savePath), true)
|
||||||
|
validZip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (!validZip) {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.save_file_invalid_zip_structure,
|
||||||
|
R.string.save_file_invalid_zip_structure_description
|
||||||
|
).show(activity.supportFragmentManager, MessageDialogFragment.TAG)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.save_file_imported_success),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheSaveDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(context, context.getString(R.string.fatal_error), Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ImportExportSavesFragment"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.TaskViewModel
|
||||||
|
|
||||||
|
|
||||||
|
class IndeterminateProgressDialogFragment : DialogFragment() {
|
||||||
|
private val taskViewModel: TaskViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val titleId = requireArguments().getInt(TITLE)
|
||||||
|
|
||||||
|
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
|
||||||
|
progressBinding.progressBar.isIndeterminate = true
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(titleId)
|
||||||
|
.setView(progressBinding.root)
|
||||||
|
.create()
|
||||||
|
dialog.setCanceledOnTouchOutside(false)
|
||||||
|
|
||||||
|
taskViewModel.isComplete.observe(this) { complete ->
|
||||||
|
if (complete) {
|
||||||
|
dialog.dismiss()
|
||||||
|
when (val result = taskViewModel.result.value) {
|
||||||
|
is String -> Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show()
|
||||||
|
is MessageDialogFragment -> result.show(
|
||||||
|
parentFragmentManager,
|
||||||
|
MessageDialogFragment.TAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
taskViewModel.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (taskViewModel.isRunning.value == false) {
|
||||||
|
taskViewModel.runTask()
|
||||||
|
}
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "IndeterminateProgressDialogFragment"
|
||||||
|
|
||||||
|
private const val TITLE = "Title"
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
activity: AppCompatActivity,
|
||||||
|
titleId: Int,
|
||||||
|
task: () -> Any
|
||||||
|
): IndeterminateProgressDialogFragment {
|
||||||
|
val dialog = IndeterminateProgressDialogFragment()
|
||||||
|
val args = Bundle()
|
||||||
|
ViewModelProvider(activity)[TaskViewModel::class.java].task = task
|
||||||
|
args.putInt(TITLE, titleId)
|
||||||
|
dialog.arguments = args
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogLicenseBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.License
|
||||||
|
import org.yuzu.yuzu_emu.utils.SerializableHelper.parcelable
|
||||||
|
|
||||||
|
class LicenseBottomSheetDialogFragment : BottomSheetDialogFragment() {
|
||||||
|
private var _binding: DialogLicenseBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = DialogLicenseBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
BottomSheetBehavior.from<View>(view.parent as View).state =
|
||||||
|
BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||||
|
|
||||||
|
val license = requireArguments().parcelable<License>(LICENSE)!!
|
||||||
|
|
||||||
|
binding.apply {
|
||||||
|
textTitle.setText(license.titleId)
|
||||||
|
textLink.setText(license.linkId)
|
||||||
|
textCopyright.setText(license.copyrightId)
|
||||||
|
textLicense.setText(license.licenseId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "LicenseBottomSheetDialogFragment"
|
||||||
|
|
||||||
|
const val LICENSE = "License"
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
license: License
|
||||||
|
): LicenseBottomSheetDialogFragment {
|
||||||
|
val dialog = LicenseBottomSheetDialogFragment()
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.putParcelable(LICENSE, license)
|
||||||
|
dialog.arguments = bundle
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,137 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.transition.MaterialSharedAxis
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.adapters.LicenseAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentLicensesBinding
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.License
|
||||||
|
|
||||||
|
class LicensesFragment : Fragment() {
|
||||||
|
private var _binding: FragmentLicensesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true)
|
||||||
|
returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentLicensesBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = false, animated = true)
|
||||||
|
homeViewModel.setStatusBarShadeVisibility(visible = false)
|
||||||
|
|
||||||
|
binding.toolbarLicenses.setNavigationOnClickListener {
|
||||||
|
binding.root.findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
val licenses = listOf(
|
||||||
|
License(
|
||||||
|
R.string.license_fidelityfx_fsr,
|
||||||
|
R.string.license_fidelityfx_fsr_description,
|
||||||
|
R.string.license_fidelityfx_fsr_link,
|
||||||
|
R.string.license_fidelityfx_fsr_copyright,
|
||||||
|
R.string.license_fidelityfx_fsr_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_cubeb,
|
||||||
|
R.string.license_cubeb_description,
|
||||||
|
R.string.license_cubeb_link,
|
||||||
|
R.string.license_cubeb_copyright,
|
||||||
|
R.string.license_cubeb_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_dynarmic,
|
||||||
|
R.string.license_dynarmic_description,
|
||||||
|
R.string.license_dynarmic_link,
|
||||||
|
R.string.license_dynarmic_copyright,
|
||||||
|
R.string.license_dynarmic_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_ffmpeg,
|
||||||
|
R.string.license_ffmpeg_description,
|
||||||
|
R.string.license_ffmpeg_link,
|
||||||
|
R.string.license_ffmpeg_copyright,
|
||||||
|
R.string.license_ffmpeg_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_opus,
|
||||||
|
R.string.license_opus_description,
|
||||||
|
R.string.license_opus_link,
|
||||||
|
R.string.license_opus_copyright,
|
||||||
|
R.string.license_opus_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_sirit,
|
||||||
|
R.string.license_sirit_description,
|
||||||
|
R.string.license_sirit_link,
|
||||||
|
R.string.license_sirit_copyright,
|
||||||
|
R.string.license_sirit_text
|
||||||
|
),
|
||||||
|
License(
|
||||||
|
R.string.license_adreno_tools,
|
||||||
|
R.string.license_adreno_tools_description,
|
||||||
|
R.string.license_adreno_tools_link,
|
||||||
|
R.string.license_adreno_tools_copyright,
|
||||||
|
R.string.license_adreno_tools_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.listLicenses.apply {
|
||||||
|
layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
adapter = LicenseAdapter(requireActivity() as AppCompatActivity, licenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
|
||||||
|
val leftInsets = barInsets.left + cutoutInsets.left
|
||||||
|
val rightInsets = barInsets.right + cutoutInsets.right
|
||||||
|
|
||||||
|
val mlpAppBar = binding.appbarLicenses.layoutParams as MarginLayoutParams
|
||||||
|
mlpAppBar.leftMargin = leftInsets
|
||||||
|
mlpAppBar.rightMargin = rightInsets
|
||||||
|
binding.appbarLicenses.layoutParams = mlpAppBar
|
||||||
|
|
||||||
|
val mlpScrollAbout = binding.listLicenses.layoutParams as MarginLayoutParams
|
||||||
|
mlpScrollAbout.leftMargin = leftInsets
|
||||||
|
mlpScrollAbout.rightMargin = rightInsets
|
||||||
|
binding.listLicenses.layoutParams = mlpScrollAbout
|
||||||
|
|
||||||
|
binding.listLicenses.updatePadding(bottom = barInsets.bottom)
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
|
||||||
|
class MessageDialogFragment : DialogFragment() {
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val titleId = requireArguments().getInt(TITLE)
|
||||||
|
val descriptionId = requireArguments().getInt(DESCRIPTION)
|
||||||
|
val helpLinkId = requireArguments().getInt(HELP_LINK)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setPositiveButton(R.string.close, null)
|
||||||
|
.setTitle(titleId)
|
||||||
|
.setMessage(descriptionId)
|
||||||
|
|
||||||
|
if (helpLinkId != 0) {
|
||||||
|
dialog.setNeutralButton(R.string.learn_more) { _, _ ->
|
||||||
|
openLink(getString(helpLinkId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openLink(link: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "MessageDialogFragment"
|
||||||
|
|
||||||
|
private const val TITLE = "Title"
|
||||||
|
private const val DESCRIPTION = "Description"
|
||||||
|
private const val HELP_LINK = "Link"
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
helpLinkId: Int = 0
|
||||||
|
): MessageDialogFragment {
|
||||||
|
val dialog = MessageDialogFragment()
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.apply {
|
||||||
|
putInt(TITLE, titleId)
|
||||||
|
putInt(DESCRIPTION, descriptionId)
|
||||||
|
putInt(HELP_LINK, helpLinkId)
|
||||||
|
}
|
||||||
|
dialog.arguments = bundle
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
|
||||||
|
class PermissionDeniedDialogFragment : DialogFragment() {
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setPositiveButton(R.string.home_settings) { _: DialogInterface?, _: Int ->
|
||||||
|
openSettings()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setTitle(R.string.permission_denied)
|
||||||
|
.setMessage(R.string.permission_denied_description)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSettings() {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
val uri = Uri.fromParts("package", requireActivity().packageName, null)
|
||||||
|
intent.data = uri
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "PermissionDeniedDialogFragment"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
||||||
|
|
||||||
|
class ResetSettingsDialogFragment : DialogFragment() {
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val settingsActivity = requireActivity() as SettingsActivity
|
||||||
|
|
||||||
|
return MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.reset_all_settings)
|
||||||
|
.setMessage(R.string.reset_all_settings_description)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
settingsActivity.onSettingsReset()
|
||||||
|
}
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "ResetSettingsDialogFragment"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,236 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.doOnTextChanged
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import info.debatty.java.stringsimilarity.Jaccard
|
||||||
|
import info.debatty.java.stringsimilarity.JaroWinkler
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentSearchBinding
|
||||||
|
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
|
||||||
|
import org.yuzu.yuzu_emu.model.Game
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.utils.FileUtil
|
||||||
|
import org.yuzu.yuzu_emu.utils.Log
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class SearchFragment : Fragment() {
|
||||||
|
private var _binding: FragmentSearchBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private lateinit var preferences: SharedPreferences
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val SEARCH_TEXT = "SearchText"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSearchBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = true, animated = false)
|
||||||
|
preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
binding.searchText.setText(savedInstanceState.getString(SEARCH_TEXT))
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.gridGamesSearch.apply {
|
||||||
|
layoutManager = AutofitGridLayoutManager(
|
||||||
|
requireContext(),
|
||||||
|
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
||||||
|
)
|
||||||
|
adapter = GameAdapter(requireActivity() as AppCompatActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.chipGroup.setOnCheckedStateChangeListener { _, _ -> filterAndSearch() }
|
||||||
|
|
||||||
|
binding.searchText.doOnTextChanged { text: CharSequence?, _: Int, _: Int, _: Int ->
|
||||||
|
if (text.toString().isNotEmpty()) {
|
||||||
|
binding.clearButton.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.clearButton.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
filterAndSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
gamesViewModel.apply {
|
||||||
|
searchFocused.observe(viewLifecycleOwner) { searchFocused ->
|
||||||
|
if (searchFocused) {
|
||||||
|
focusSearch()
|
||||||
|
gamesViewModel.setSearchFocused(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
games.observe(viewLifecycleOwner) { filterAndSearch() }
|
||||||
|
searchedGames.observe(viewLifecycleOwner) {
|
||||||
|
(binding.gridGamesSearch.adapter as GameAdapter).submitList(it)
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
binding.noResultsView.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.noResultsView.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.clearButton.setOnClickListener { binding.searchText.setText("") }
|
||||||
|
|
||||||
|
binding.searchBackground.setOnClickListener { focusSearch() }
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
filterAndSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class ScoredGame(val score: Double, val item: Game)
|
||||||
|
|
||||||
|
private fun filterAndSearch() {
|
||||||
|
val baseList = gamesViewModel.games.value!!
|
||||||
|
val filteredList: List<Game> = when (binding.chipGroup.checkedChipId) {
|
||||||
|
R.id.chip_recently_played -> {
|
||||||
|
baseList.filter {
|
||||||
|
val lastPlayedTime = preferences.getLong(it.keyLastPlayedTime, 0L)
|
||||||
|
lastPlayedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_recently_added -> {
|
||||||
|
baseList.filter {
|
||||||
|
val addedTime = preferences.getLong(it.keyAddedToLibraryTime, 0L)
|
||||||
|
addedTime > (System.currentTimeMillis() - 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_homebrew -> {
|
||||||
|
baseList.filter {
|
||||||
|
Log.error("Guh - ${it.path}")
|
||||||
|
FileUtil.hasExtension(it.path, "nro")
|
||||||
|
|| FileUtil.hasExtension(it.path, "nso")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.chip_retail -> baseList.filter {
|
||||||
|
FileUtil.hasExtension(it.path, "xci")
|
||||||
|
|| FileUtil.hasExtension(it.path, "nsp")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> baseList
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.searchText.text.toString().isEmpty()
|
||||||
|
&& binding.chipGroup.checkedChipId != View.NO_ID
|
||||||
|
) {
|
||||||
|
gamesViewModel.setSearchedGames(filteredList)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val searchTerm = binding.searchText.text.toString().lowercase(Locale.getDefault())
|
||||||
|
val searchAlgorithm = if (searchTerm.length > 1) Jaccard(2) else JaroWinkler()
|
||||||
|
val sortedList: List<Game> = filteredList.mapNotNull { game ->
|
||||||
|
val title = game.title.lowercase(Locale.getDefault())
|
||||||
|
val score = searchAlgorithm.similarity(searchTerm, title)
|
||||||
|
if (score > 0.03) {
|
||||||
|
ScoredGame(score, game)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedByDescending { it.score }.map { it.item }
|
||||||
|
gamesViewModel.setSearchedGames(sortedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
if (_binding != null) {
|
||||||
|
outState.putString(SEARCH_TEXT, binding.searchText.text.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun focusSearch() {
|
||||||
|
if (_binding != null) {
|
||||||
|
binding.searchText.requestFocus()
|
||||||
|
val imm =
|
||||||
|
requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager?
|
||||||
|
imm?.showSoftInput(binding.searchText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_med)
|
||||||
|
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||||
|
val spacingNavigationRail =
|
||||||
|
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||||
|
val chipSpacing = resources.getDimensionPixelSize(R.dimen.spacing_chip)
|
||||||
|
|
||||||
|
binding.constraintSearch.updatePadding(
|
||||||
|
left = barInsets.left + cutoutInsets.left,
|
||||||
|
top = barInsets.top,
|
||||||
|
right = barInsets.right + cutoutInsets.right
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.gridGamesSearch.updatePadding(
|
||||||
|
top = extraListSpacing,
|
||||||
|
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
|
||||||
|
)
|
||||||
|
binding.noResultsView.updatePadding(bottom = spacingNavigation + barInsets.bottom)
|
||||||
|
|
||||||
|
val mlpDivider = binding.divider.layoutParams as ViewGroup.MarginLayoutParams
|
||||||
|
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
binding.frameSearch.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.gridGamesSearch.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.noResultsView.updatePadding(left = spacingNavigationRail)
|
||||||
|
binding.chipGroup.updatePadding(
|
||||||
|
left = chipSpacing + spacingNavigationRail,
|
||||||
|
right = chipSpacing
|
||||||
|
)
|
||||||
|
mlpDivider.leftMargin = chipSpacing + spacingNavigationRail
|
||||||
|
mlpDivider.rightMargin = chipSpacing
|
||||||
|
} else {
|
||||||
|
binding.frameSearch.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.gridGamesSearch.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.noResultsView.updatePadding(right = spacingNavigationRail)
|
||||||
|
binding.chipGroup.updatePadding(
|
||||||
|
left = chipSpacing,
|
||||||
|
right = chipSpacing + spacingNavigationRail
|
||||||
|
)
|
||||||
|
mlpDivider.leftMargin = chipSpacing
|
||||||
|
mlpDivider.rightMargin = chipSpacing + spacingNavigationRail
|
||||||
|
}
|
||||||
|
binding.divider.layoutParams = mlpDivider
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,329 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
|
||||||
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.adapters.SetupAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentSetupBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.SetupPage
|
||||||
|
import org.yuzu.yuzu_emu.ui.main.MainActivity
|
||||||
|
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
|
||||||
|
import org.yuzu.yuzu_emu.utils.GameHelper
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class SetupFragment : Fragment() {
|
||||||
|
private var _binding: FragmentSetupBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private lateinit var mainActivity: MainActivity
|
||||||
|
|
||||||
|
private lateinit var hasBeenWarned: BooleanArray
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_NEXT_VISIBILITY = "NextButtonVisibility"
|
||||||
|
const val KEY_BACK_VISIBILITY = "BackButtonVisibility"
|
||||||
|
const val KEY_HAS_BEEN_WARNED = "HasBeenWarned"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
exitTransition = MaterialFadeThrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentSetupBinding.inflate(layoutInflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
mainActivity = requireActivity() as MainActivity
|
||||||
|
|
||||||
|
homeViewModel.setNavigationVisibility(visible = false, animated = false)
|
||||||
|
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (binding.viewPager2.currentItem > 0) {
|
||||||
|
pageBackward()
|
||||||
|
} else {
|
||||||
|
requireActivity().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
requireActivity().window.navigationBarColor =
|
||||||
|
ContextCompat.getColor(requireContext(), android.R.color.transparent)
|
||||||
|
|
||||||
|
val pages = mutableListOf<SetupPage>()
|
||||||
|
pages.apply {
|
||||||
|
add(
|
||||||
|
SetupPage(
|
||||||
|
R.drawable.ic_yuzu_title,
|
||||||
|
R.string.welcome,
|
||||||
|
R.string.welcome_description,
|
||||||
|
0,
|
||||||
|
true,
|
||||||
|
R.string.get_started,
|
||||||
|
{ pageForward() },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(
|
||||||
|
SetupPage(
|
||||||
|
R.drawable.ic_notification,
|
||||||
|
R.string.notifications,
|
||||||
|
R.string.notifications_description,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
R.string.give_permission,
|
||||||
|
{ permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||||
|
true,
|
||||||
|
R.string.notification_warning,
|
||||||
|
R.string.notification_warning_description,
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
NotificationManagerCompat.from(requireContext())
|
||||||
|
.areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
SetupPage(
|
||||||
|
R.drawable.ic_key,
|
||||||
|
R.string.keys,
|
||||||
|
R.string.keys_description,
|
||||||
|
R.drawable.ic_add,
|
||||||
|
true,
|
||||||
|
R.string.select_keys,
|
||||||
|
{ mainActivity.getProdKey.launch(arrayOf("*/*")) },
|
||||||
|
true,
|
||||||
|
R.string.install_prod_keys_warning,
|
||||||
|
R.string.install_prod_keys_warning_description,
|
||||||
|
R.string.install_prod_keys_warning_help,
|
||||||
|
{ File(DirectoryInitialization.userDirectory + "/keys/prod.keys").exists() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SetupPage(
|
||||||
|
R.drawable.ic_controller,
|
||||||
|
R.string.games,
|
||||||
|
R.string.games_description,
|
||||||
|
R.drawable.ic_add,
|
||||||
|
true,
|
||||||
|
R.string.add_games,
|
||||||
|
{ mainActivity.getGamesDirectory.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).data) },
|
||||||
|
true,
|
||||||
|
R.string.add_games_warning,
|
||||||
|
R.string.add_games_warning_description,
|
||||||
|
R.string.add_games_warning_help,
|
||||||
|
{
|
||||||
|
val preferences =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
preferences.getString(GameHelper.KEY_GAME_PATH, "")!!.isNotEmpty()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
SetupPage(
|
||||||
|
R.drawable.ic_check,
|
||||||
|
R.string.done,
|
||||||
|
R.string.done_description,
|
||||||
|
R.drawable.ic_arrow_forward,
|
||||||
|
false,
|
||||||
|
R.string.text_continue,
|
||||||
|
{ finishSetup() },
|
||||||
|
false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.viewPager2.apply {
|
||||||
|
adapter = SetupAdapter(requireActivity() as AppCompatActivity, pages)
|
||||||
|
offscreenPageLimit = 2
|
||||||
|
isUserInputEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.viewPager2.registerOnPageChangeCallback(object : OnPageChangeCallback() {
|
||||||
|
var previousPosition: Int = 0
|
||||||
|
|
||||||
|
override fun onPageSelected(position: Int) {
|
||||||
|
super.onPageSelected(position)
|
||||||
|
|
||||||
|
if (position == 1 && previousPosition == 0) {
|
||||||
|
showView(binding.buttonNext)
|
||||||
|
showView(binding.buttonBack)
|
||||||
|
} else if (position == 0 && previousPosition == 1) {
|
||||||
|
hideView(binding.buttonBack)
|
||||||
|
hideView(binding.buttonNext)
|
||||||
|
} else if (position == pages.size - 1 && previousPosition == pages.size - 2) {
|
||||||
|
hideView(binding.buttonNext)
|
||||||
|
} else if (position == pages.size - 2 && previousPosition == pages.size - 1) {
|
||||||
|
showView(binding.buttonNext)
|
||||||
|
}
|
||||||
|
|
||||||
|
previousPosition = position
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
binding.buttonNext.setOnClickListener {
|
||||||
|
val index = binding.viewPager2.currentItem
|
||||||
|
val currentPage = pages[index]
|
||||||
|
|
||||||
|
// Checks if the user has completed the task on the current page
|
||||||
|
if (currentPage.hasWarning) {
|
||||||
|
if (currentPage.taskCompleted.invoke()) {
|
||||||
|
pageForward()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasBeenWarned[index]) {
|
||||||
|
SetupWarningDialogFragment.newInstance(
|
||||||
|
currentPage.warningTitleId,
|
||||||
|
currentPage.warningDescriptionId,
|
||||||
|
currentPage.warningHelpLinkId,
|
||||||
|
index
|
||||||
|
).show(childFragmentManager, SetupWarningDialogFragment.TAG)
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pageForward()
|
||||||
|
}
|
||||||
|
binding.buttonBack.setOnClickListener { pageBackward() }
|
||||||
|
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
val nextIsVisible = savedInstanceState.getBoolean(KEY_NEXT_VISIBILITY)
|
||||||
|
val backIsVisible = savedInstanceState.getBoolean(KEY_BACK_VISIBILITY)
|
||||||
|
hasBeenWarned = savedInstanceState.getBooleanArray(KEY_HAS_BEEN_WARNED)!!
|
||||||
|
|
||||||
|
if (nextIsVisible) {
|
||||||
|
binding.buttonNext.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
if (backIsVisible) {
|
||||||
|
binding.buttonBack.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hasBeenWarned = BooleanArray(pages.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putBoolean(KEY_NEXT_VISIBILITY, binding.buttonNext.isVisible)
|
||||||
|
outState.putBoolean(KEY_BACK_VISIBILITY, binding.buttonBack.isVisible)
|
||||||
|
outState.putBooleanArray(KEY_HAS_BEEN_WARNED, hasBeenWarned)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||||
|
if (!it && !shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
|
||||||
|
PermissionDeniedDialogFragment().show(
|
||||||
|
childFragmentManager,
|
||||||
|
PermissionDeniedDialogFragment.TAG
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishSetup() {
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext).edit()
|
||||||
|
.putBoolean(Settings.PREF_FIRST_APP_LAUNCH, false)
|
||||||
|
.apply()
|
||||||
|
mainActivity.finishSetup(binding.root.findNavController())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showView(view: View) {
|
||||||
|
view.apply {
|
||||||
|
alpha = 0f
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
isClickable = true
|
||||||
|
}.animate().apply {
|
||||||
|
duration = 300
|
||||||
|
alpha(1f)
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideView(view: View) {
|
||||||
|
if (view.visibility == View.INVISIBLE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view.apply {
|
||||||
|
alpha = 1f
|
||||||
|
isClickable = false
|
||||||
|
}.animate().apply {
|
||||||
|
duration = 300
|
||||||
|
alpha(0f)
|
||||||
|
}.withEndAction {
|
||||||
|
view.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pageForward() {
|
||||||
|
binding.viewPager2.currentItem = binding.viewPager2.currentItem + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pageBackward() {
|
||||||
|
binding.viewPager2.currentItem = binding.viewPager2.currentItem - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPageWarned(page: Int) {
|
||||||
|
hasBeenWarned[page] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
view.setPadding(
|
||||||
|
barInsets.left + cutoutInsets.left,
|
||||||
|
barInsets.top + cutoutInsets.top,
|
||||||
|
barInsets.right + cutoutInsets.right,
|
||||||
|
barInsets.bottom + cutoutInsets.bottom
|
||||||
|
)
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.fragments
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
|
||||||
|
class SetupWarningDialogFragment : DialogFragment() {
|
||||||
|
private var titleId: Int = 0
|
||||||
|
private var descriptionId: Int = 0
|
||||||
|
private var helpLinkId: Int = 0
|
||||||
|
private var page: Int = 0
|
||||||
|
|
||||||
|
private lateinit var setupFragment: SetupFragment
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
titleId = requireArguments().getInt(TITLE)
|
||||||
|
descriptionId = requireArguments().getInt(DESCRIPTION)
|
||||||
|
helpLinkId = requireArguments().getInt(HELP_LINK)
|
||||||
|
page = requireArguments().getInt(PAGE)
|
||||||
|
|
||||||
|
setupFragment = requireParentFragment() as SetupFragment
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setPositiveButton(R.string.warning_skip) { _: DialogInterface?, _: Int ->
|
||||||
|
setupFragment.pageForward()
|
||||||
|
setupFragment.setPageWarned(page)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.warning_cancel, null)
|
||||||
|
|
||||||
|
if (titleId != 0) {
|
||||||
|
builder.setTitle(titleId)
|
||||||
|
} else {
|
||||||
|
builder.setTitle("")
|
||||||
|
}
|
||||||
|
if (descriptionId != 0) {
|
||||||
|
builder.setMessage(descriptionId)
|
||||||
|
}
|
||||||
|
if (helpLinkId != 0) {
|
||||||
|
builder.setNeutralButton(R.string.warning_help) { _: DialogInterface?, _: Int ->
|
||||||
|
val helpLink = resources.getString(R.string.install_prod_keys_warning_help)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(helpLink))
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "SetupWarningDialogFragment"
|
||||||
|
|
||||||
|
private const val TITLE = "Title"
|
||||||
|
private const val DESCRIPTION = "Description"
|
||||||
|
private const val HELP_LINK = "HelpLink"
|
||||||
|
private const val PAGE = "Page"
|
||||||
|
|
||||||
|
fun newInstance(
|
||||||
|
titleId: Int,
|
||||||
|
descriptionId: Int,
|
||||||
|
helpLinkId: Int,
|
||||||
|
page: Int
|
||||||
|
): SetupWarningDialogFragment {
|
||||||
|
val dialog = SetupWarningDialogFragment()
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.apply {
|
||||||
|
putInt(TITLE, titleId)
|
||||||
|
putInt(DESCRIPTION, descriptionId)
|
||||||
|
putInt(HELP_LINK, helpLinkId)
|
||||||
|
putInt(PAGE, page)
|
||||||
|
}
|
||||||
|
dialog.arguments = bundle
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.layout
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView.Recycler
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cut down version of the solution provided here
|
||||||
|
* https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count
|
||||||
|
*/
|
||||||
|
class AutofitGridLayoutManager(
|
||||||
|
context: Context,
|
||||||
|
columnWidth: Int
|
||||||
|
) : GridLayoutManager(context, 1) {
|
||||||
|
private var columnWidth = 0
|
||||||
|
private var isColumnWidthChanged = true
|
||||||
|
private var lastWidth = 0
|
||||||
|
private var lastHeight = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
setColumnWidth(checkedColumnWidth(context, columnWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkedColumnWidth(context: Context, columnWidth: Int): Int {
|
||||||
|
var newColumnWidth = columnWidth
|
||||||
|
if (newColumnWidth <= 0) {
|
||||||
|
newColumnWidth = context.resources.getDimensionPixelSize(R.dimen.spacing_xtralarge)
|
||||||
|
}
|
||||||
|
return newColumnWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setColumnWidth(newColumnWidth: Int) {
|
||||||
|
if (newColumnWidth > 0 && newColumnWidth != columnWidth) {
|
||||||
|
columnWidth = newColumnWidth
|
||||||
|
isColumnWidthChanged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLayoutChildren(recycler: Recycler, state: RecyclerView.State) {
|
||||||
|
val width = width
|
||||||
|
val height = height
|
||||||
|
if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) {
|
||||||
|
val totalSpace: Int = if (orientation == VERTICAL) {
|
||||||
|
width - paddingRight - paddingLeft
|
||||||
|
} else {
|
||||||
|
height - paddingTop - paddingBottom
|
||||||
|
}
|
||||||
|
val spanCount = 1.coerceAtLeast(totalSpace / columnWidth)
|
||||||
|
setSpanCount(spanCount)
|
||||||
|
isColumnWidthChanged = false
|
||||||
|
}
|
||||||
|
lastWidth = width
|
||||||
|
lastHeight = height
|
||||||
|
super.onLayoutChildren(recycler, state)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.util.HashSet
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
@Serializable
|
||||||
|
class Game(
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val regions: String,
|
||||||
|
val path: String,
|
||||||
|
val gameId: String,
|
||||||
|
val company: String
|
||||||
|
) : Parcelable {
|
||||||
|
val keyAddedToLibraryTime get() = "${gameId}_AddedToLibraryTime"
|
||||||
|
val keyLastPlayedTime get() = "${gameId}_LastPlayed"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (other !is Game)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return title == other.title
|
||||||
|
&& description == other.description
|
||||||
|
&& regions == other.regions
|
||||||
|
&& path == other.path
|
||||||
|
&& gameId == other.gameId
|
||||||
|
&& company == other.company
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val extensions: Set<String> = HashSet(
|
||||||
|
listOf(".xci", ".nsp", ".nca", ".nro")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,109 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.YuzuApplication
|
||||||
|
import org.yuzu.yuzu_emu.utils.GameHelper
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class GamesViewModel : ViewModel() {
|
||||||
|
private val _games = MutableLiveData<List<Game>>(emptyList())
|
||||||
|
val games: LiveData<List<Game>> get() = _games
|
||||||
|
|
||||||
|
private val _searchedGames = MutableLiveData<List<Game>>(emptyList())
|
||||||
|
val searchedGames: LiveData<List<Game>> get() = _searchedGames
|
||||||
|
|
||||||
|
private val _isReloading = MutableLiveData(false)
|
||||||
|
val isReloading: LiveData<Boolean> get() = _isReloading
|
||||||
|
|
||||||
|
private val _shouldSwapData = MutableLiveData(false)
|
||||||
|
val shouldSwapData: LiveData<Boolean> get() = _shouldSwapData
|
||||||
|
|
||||||
|
private val _shouldScrollToTop = MutableLiveData(false)
|
||||||
|
val shouldScrollToTop: LiveData<Boolean> get() = _shouldScrollToTop
|
||||||
|
|
||||||
|
private val _searchFocused = MutableLiveData(false)
|
||||||
|
val searchFocused: LiveData<Boolean> get() = _searchFocused
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||||
|
NativeLibrary.reloadKeys()
|
||||||
|
|
||||||
|
// Retrieve list of cached games
|
||||||
|
val storedGames = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
|
||||||
|
.getStringSet(GameHelper.KEY_GAMES, emptySet())
|
||||||
|
if (storedGames!!.isNotEmpty()) {
|
||||||
|
val deserializedGames = mutableSetOf<Game>()
|
||||||
|
storedGames.forEach {
|
||||||
|
val game: Game = Json.decodeFromString(it)
|
||||||
|
val gameExists =
|
||||||
|
DocumentFile.fromSingleUri(YuzuApplication.appContext, Uri.parse(game.path))
|
||||||
|
?.exists()
|
||||||
|
if (gameExists == true) {
|
||||||
|
deserializedGames.add(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGames(deserializedGames.toList())
|
||||||
|
}
|
||||||
|
reloadGames(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGames(games: List<Game>) {
|
||||||
|
val sortedList = games.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ it.title.lowercase(Locale.getDefault()) },
|
||||||
|
{ it.path }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_games.postValue(sortedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchedGames(games: List<Game>) {
|
||||||
|
_searchedGames.postValue(games)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShouldSwapData(shouldSwap: Boolean) {
|
||||||
|
_shouldSwapData.postValue(shouldSwap)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShouldScrollToTop(shouldScroll: Boolean) {
|
||||||
|
_shouldScrollToTop.postValue(shouldScroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearchFocused(searchFocused: Boolean) {
|
||||||
|
_searchFocused.postValue(searchFocused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadGames(directoryChanged: Boolean) {
|
||||||
|
if (isReloading.value == true)
|
||||||
|
return
|
||||||
|
_isReloading.postValue(true)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
NativeLibrary.resetRomMetadata()
|
||||||
|
setGames(GameHelper.getGames())
|
||||||
|
_isReloading.postValue(false)
|
||||||
|
|
||||||
|
if (directoryChanged) {
|
||||||
|
setShouldSwapData(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
data class HomeSetting(
|
||||||
|
val titleId: Int,
|
||||||
|
val descriptionId: Int,
|
||||||
|
val iconId: Int,
|
||||||
|
val onClick: () -> Unit
|
||||||
|
)
|
@ -0,0 +1,36 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
||||||
|
class HomeViewModel : ViewModel() {
|
||||||
|
private val _navigationVisible = MutableLiveData<Pair<Boolean, Boolean>>()
|
||||||
|
val navigationVisible: LiveData<Pair<Boolean, Boolean>> get() = _navigationVisible
|
||||||
|
|
||||||
|
private val _statusBarShadeVisible = MutableLiveData(true)
|
||||||
|
val statusBarShadeVisible: LiveData<Boolean> get() = _statusBarShadeVisible
|
||||||
|
|
||||||
|
var navigatedToSetup = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
_navigationVisible.value = Pair(false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNavigationVisibility(visible: Boolean, animated: Boolean) {
|
||||||
|
if (_navigationVisible.value?.first == visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_navigationVisible.value = Pair(visible, animated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStatusBarShadeVisibility(visible: Boolean) {
|
||||||
|
if (_statusBarShadeVisible.value == visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_statusBarShadeVisible.value = visible
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class License(
|
||||||
|
val titleId: Int,
|
||||||
|
val descriptionId: Int,
|
||||||
|
val linkId: Int,
|
||||||
|
val copyrightId: Int,
|
||||||
|
val licenseId: Int
|
||||||
|
) : Parcelable
|
@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
|
||||||
|
class MinimalDocumentFile(val filename: String, mimeType: String, val uri: Uri) {
|
||||||
|
val isDirectory: Boolean = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
data class SetupPage(
|
||||||
|
val iconId: Int,
|
||||||
|
val titleId: Int,
|
||||||
|
val descriptionId: Int,
|
||||||
|
val buttonIconId: Int,
|
||||||
|
val leftAlignedIcon: Boolean,
|
||||||
|
val buttonTextId: Int,
|
||||||
|
val buttonAction: () -> Unit,
|
||||||
|
val hasWarning: Boolean,
|
||||||
|
val warningTitleId: Int = 0,
|
||||||
|
val warningDescriptionId: Int = 0,
|
||||||
|
val warningHelpLinkId: Int = 0,
|
||||||
|
val taskCompleted: () -> Boolean = { true }
|
||||||
|
)
|
@ -0,0 +1,47 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.model
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TaskViewModel : ViewModel() {
|
||||||
|
private val _result = MutableLiveData<Any>()
|
||||||
|
val result: LiveData<Any> = _result
|
||||||
|
|
||||||
|
private val _isComplete = MutableLiveData<Boolean>()
|
||||||
|
val isComplete: LiveData<Boolean> = _isComplete
|
||||||
|
|
||||||
|
private val _isRunning = MutableLiveData<Boolean>()
|
||||||
|
val isRunning: LiveData<Boolean> = _isRunning
|
||||||
|
|
||||||
|
lateinit var task: () -> Any
|
||||||
|
|
||||||
|
init {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_result.value = Any()
|
||||||
|
_isComplete.value = false
|
||||||
|
_isRunning.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTask() {
|
||||||
|
if (_isRunning.value == true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_isRunning.value = true
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val res = task()
|
||||||
|
_result.postValue(res)
|
||||||
|
_isComplete.postValue(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,148 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.overlay
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom [BitmapDrawable] that is capable
|
||||||
|
* of storing it's own ID.
|
||||||
|
*
|
||||||
|
* @param res [Resources] instance.
|
||||||
|
* @param defaultStateBitmap [Bitmap] to use with the default state Drawable.
|
||||||
|
* @param pressedStateBitmap [Bitmap] to use with the pressed state Drawable.
|
||||||
|
* @param buttonId Identifier for this type of button.
|
||||||
|
*/
|
||||||
|
class InputOverlayDrawableButton(
|
||||||
|
res: Resources,
|
||||||
|
defaultStateBitmap: Bitmap,
|
||||||
|
pressedStateBitmap: Bitmap,
|
||||||
|
val buttonId: Int
|
||||||
|
) {
|
||||||
|
// The ID value what motion event is tracking
|
||||||
|
var trackId: Int
|
||||||
|
|
||||||
|
// The drawable position on the screen
|
||||||
|
private var buttonPositionX = 0
|
||||||
|
private var buttonPositionY = 0
|
||||||
|
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
|
||||||
|
private val defaultStateBitmap: BitmapDrawable
|
||||||
|
private val pressedStateBitmap: BitmapDrawable
|
||||||
|
private var pressedState = false
|
||||||
|
|
||||||
|
private var previousTouchX = 0
|
||||||
|
private var previousTouchY = 0
|
||||||
|
var controlPositionX = 0
|
||||||
|
var controlPositionY = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
|
||||||
|
this.pressedStateBitmap = BitmapDrawable(res, pressedStateBitmap)
|
||||||
|
trackId = -1
|
||||||
|
width = this.defaultStateBitmap.intrinsicWidth
|
||||||
|
height = this.defaultStateBitmap.intrinsicHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates button status based on the motion event.
|
||||||
|
*
|
||||||
|
* @return true if value was changed
|
||||||
|
*/
|
||||||
|
fun updateStatus(event: MotionEvent): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val xPosition = event.getX(pointerIndex).toInt()
|
||||||
|
val yPosition = event.getY(pointerIndex).toInt()
|
||||||
|
val pointerId = event.getPointerId(pointerIndex)
|
||||||
|
val motionEvent = event.action and MotionEvent.ACTION_MASK
|
||||||
|
val isActionDown =
|
||||||
|
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
|
||||||
|
val isActionUp =
|
||||||
|
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
|
||||||
|
|
||||||
|
if (isActionDown) {
|
||||||
|
if (!bounds.contains(xPosition, yPosition)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pressedState = true
|
||||||
|
trackId = pointerId
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActionUp) {
|
||||||
|
if (trackId != pointerId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pressedState = false
|
||||||
|
trackId = -1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPosition(x: Int, y: Int) {
|
||||||
|
buttonPositionX = x
|
||||||
|
buttonPositionY = y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(canvas: Canvas?) {
|
||||||
|
currentStateBitmapDrawable.draw(canvas!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val currentStateBitmapDrawable: BitmapDrawable
|
||||||
|
get() = if (pressedState) pressedStateBitmap else defaultStateBitmap
|
||||||
|
|
||||||
|
fun onConfigureTouch(event: MotionEvent): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val fingerPositionX = event.getX(pointerIndex).toInt()
|
||||||
|
val fingerPositionY = event.getY(pointerIndex).toInt()
|
||||||
|
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
controlPositionX = fingerPositionX - (width / 2)
|
||||||
|
controlPositionY = fingerPositionY - (height / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
controlPositionX += fingerPositionX - previousTouchX
|
||||||
|
controlPositionY += fingerPositionY - previousTouchY
|
||||||
|
setBounds(
|
||||||
|
controlPositionX,
|
||||||
|
controlPositionY,
|
||||||
|
width + controlPositionX,
|
||||||
|
height + controlPositionY
|
||||||
|
)
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
|
||||||
|
defaultStateBitmap.setBounds(left, top, right, bottom)
|
||||||
|
pressedStateBitmap.setBounds(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOpacity(value: Int) {
|
||||||
|
defaultStateBitmap.alpha = value
|
||||||
|
pressedStateBitmap.alpha = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val status: Int
|
||||||
|
get() = if (pressedState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
val bounds: Rect
|
||||||
|
get() = defaultStateBitmap.bounds
|
||||||
|
}
|
@ -0,0 +1,274 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.overlay
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary.ButtonState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom [BitmapDrawable] that is capable
|
||||||
|
* of storing it's own ID.
|
||||||
|
*
|
||||||
|
* @param res [Resources] instance.
|
||||||
|
* @param defaultStateBitmap [Bitmap] of the default state.
|
||||||
|
* @param pressedOneDirectionStateBitmap [Bitmap] of the pressed state in one direction.
|
||||||
|
* @param pressedTwoDirectionsStateBitmap [Bitmap] of the pressed state in two direction.
|
||||||
|
* @param buttonUp Identifier for the up button.
|
||||||
|
* @param buttonDown Identifier for the down button.
|
||||||
|
* @param buttonLeft Identifier for the left button.
|
||||||
|
* @param buttonRight Identifier for the right button.
|
||||||
|
*/
|
||||||
|
class InputOverlayDrawableDpad(
|
||||||
|
res: Resources,
|
||||||
|
defaultStateBitmap: Bitmap,
|
||||||
|
pressedOneDirectionStateBitmap: Bitmap,
|
||||||
|
pressedTwoDirectionsStateBitmap: Bitmap,
|
||||||
|
buttonUp: Int,
|
||||||
|
buttonDown: Int,
|
||||||
|
buttonLeft: Int,
|
||||||
|
buttonRight: Int
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Gets one of the InputOverlayDrawableDpad's button IDs.
|
||||||
|
*
|
||||||
|
* @return the requested InputOverlayDrawableDpad's button ID.
|
||||||
|
*/
|
||||||
|
// The ID identifying what type of button this Drawable represents.
|
||||||
|
val upId: Int
|
||||||
|
val downId: Int
|
||||||
|
val leftId: Int
|
||||||
|
val rightId: Int
|
||||||
|
var trackId: Int
|
||||||
|
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
|
||||||
|
private val defaultStateBitmap: BitmapDrawable
|
||||||
|
private val pressedOneDirectionStateBitmap: BitmapDrawable
|
||||||
|
private val pressedTwoDirectionsStateBitmap: BitmapDrawable
|
||||||
|
|
||||||
|
private var previousTouchX = 0
|
||||||
|
private var previousTouchY = 0
|
||||||
|
private var controlPositionX = 0
|
||||||
|
private var controlPositionY = 0
|
||||||
|
|
||||||
|
private var upButtonState = false
|
||||||
|
private var downButtonState = false
|
||||||
|
private var leftButtonState = false
|
||||||
|
private var rightButtonState = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.defaultStateBitmap = BitmapDrawable(res, defaultStateBitmap)
|
||||||
|
this.pressedOneDirectionStateBitmap = BitmapDrawable(res, pressedOneDirectionStateBitmap)
|
||||||
|
this.pressedTwoDirectionsStateBitmap = BitmapDrawable(res, pressedTwoDirectionsStateBitmap)
|
||||||
|
width = this.defaultStateBitmap.intrinsicWidth
|
||||||
|
height = this.defaultStateBitmap.intrinsicHeight
|
||||||
|
upId = buttonUp
|
||||||
|
downId = buttonDown
|
||||||
|
leftId = buttonLeft
|
||||||
|
rightId = buttonRight
|
||||||
|
trackId = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStatus(event: MotionEvent, dpad_slide: Boolean): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val xPosition = event.getX(pointerIndex).toInt()
|
||||||
|
val yPosition = event.getY(pointerIndex).toInt()
|
||||||
|
val pointerId = event.getPointerId(pointerIndex)
|
||||||
|
val motionEvent = event.action and MotionEvent.ACTION_MASK
|
||||||
|
val isActionDown =
|
||||||
|
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
|
||||||
|
val isActionUp =
|
||||||
|
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
|
||||||
|
if (isActionDown) {
|
||||||
|
if (!bounds.contains(xPosition, yPosition)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trackId = pointerId
|
||||||
|
}
|
||||||
|
if (isActionUp) {
|
||||||
|
if (trackId != pointerId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trackId = -1
|
||||||
|
upButtonState = false
|
||||||
|
downButtonState = false
|
||||||
|
leftButtonState = false
|
||||||
|
rightButtonState = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (trackId == -1) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!dpad_slide && !isActionDown) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
if (trackId != event.getPointerId(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var touchX = event.getX(i)
|
||||||
|
var touchY = event.getY(i)
|
||||||
|
var maxY = bounds.bottom.toFloat()
|
||||||
|
var maxX = bounds.right.toFloat()
|
||||||
|
touchX -= bounds.centerX().toFloat()
|
||||||
|
maxX -= bounds.centerX().toFloat()
|
||||||
|
touchY -= bounds.centerY().toFloat()
|
||||||
|
maxY -= bounds.centerY().toFloat()
|
||||||
|
val axisX = touchX / maxX
|
||||||
|
val axisY = touchY / maxY
|
||||||
|
val oldUpState = upButtonState
|
||||||
|
val oldDownState = downButtonState
|
||||||
|
val oldLeftState = leftButtonState
|
||||||
|
val oldRightState = rightButtonState
|
||||||
|
|
||||||
|
upButtonState = axisY < -VIRT_AXIS_DEADZONE
|
||||||
|
downButtonState = axisY > VIRT_AXIS_DEADZONE
|
||||||
|
leftButtonState = axisX < -VIRT_AXIS_DEADZONE
|
||||||
|
rightButtonState = axisX > VIRT_AXIS_DEADZONE
|
||||||
|
return oldUpState != upButtonState || oldDownState != downButtonState || oldLeftState != leftButtonState || oldRightState != rightButtonState
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(canvas: Canvas) {
|
||||||
|
val px = controlPositionX + width / 2
|
||||||
|
val py = controlPositionY + height / 2
|
||||||
|
|
||||||
|
// Pressed up
|
||||||
|
if (upButtonState && !leftButtonState && !rightButtonState) {
|
||||||
|
pressedOneDirectionStateBitmap.draw(canvas)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed down
|
||||||
|
if (downButtonState && !leftButtonState && !rightButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(180f, px.toFloat(), py.toFloat())
|
||||||
|
pressedOneDirectionStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed left
|
||||||
|
if (leftButtonState && !upButtonState && !downButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(270f, px.toFloat(), py.toFloat())
|
||||||
|
pressedOneDirectionStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed right
|
||||||
|
if (rightButtonState && !upButtonState && !downButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(90f, px.toFloat(), py.toFloat())
|
||||||
|
pressedOneDirectionStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed up left
|
||||||
|
if (upButtonState && leftButtonState && !rightButtonState) {
|
||||||
|
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed up right
|
||||||
|
if (upButtonState && !leftButtonState && rightButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(90f, px.toFloat(), py.toFloat())
|
||||||
|
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed down right
|
||||||
|
if (downButtonState && !leftButtonState && rightButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(180f, px.toFloat(), py.toFloat())
|
||||||
|
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressed down left
|
||||||
|
if (downButtonState && leftButtonState && !rightButtonState) {
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(270f, px.toFloat(), py.toFloat())
|
||||||
|
pressedTwoDirectionsStateBitmap.draw(canvas)
|
||||||
|
canvas.restore()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not pressed
|
||||||
|
defaultStateBitmap.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
val upStatus: Int
|
||||||
|
get() = if (upButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
val downStatus: Int
|
||||||
|
get() = if (downButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
val leftStatus: Int
|
||||||
|
get() = if (leftButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
val rightStatus: Int
|
||||||
|
get() = if (rightButtonState) ButtonState.PRESSED else ButtonState.RELEASED
|
||||||
|
|
||||||
|
fun onConfigureTouch(event: MotionEvent): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val fingerPositionX = event.getX(pointerIndex).toInt()
|
||||||
|
val fingerPositionY = event.getY(pointerIndex).toInt()
|
||||||
|
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
controlPositionX += fingerPositionX - previousTouchX
|
||||||
|
controlPositionY += fingerPositionY - previousTouchY
|
||||||
|
setBounds(
|
||||||
|
controlPositionX,
|
||||||
|
controlPositionY,
|
||||||
|
width + controlPositionX,
|
||||||
|
height + controlPositionY
|
||||||
|
)
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPosition(x: Int, y: Int) {
|
||||||
|
controlPositionX = x
|
||||||
|
controlPositionY = y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBounds(left: Int, top: Int, right: Int, bottom: Int) {
|
||||||
|
defaultStateBitmap.setBounds(left, top, right, bottom)
|
||||||
|
pressedOneDirectionStateBitmap.setBounds(left, top, right, bottom)
|
||||||
|
pressedTwoDirectionsStateBitmap.setBounds(left, top, right, bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOpacity(value: Int) {
|
||||||
|
defaultStateBitmap.alpha = value
|
||||||
|
pressedOneDirectionStateBitmap.alpha = value
|
||||||
|
pressedTwoDirectionsStateBitmap.alpha = value
|
||||||
|
}
|
||||||
|
|
||||||
|
val bounds: Rect
|
||||||
|
get() = defaultStateBitmap.bounds
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val VIRT_AXIS_DEADZONE = 0.5f
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,282 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.overlay
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.utils.EmulationMenuSettings
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom [BitmapDrawable] that is capable
|
||||||
|
* of storing it's own ID.
|
||||||
|
*
|
||||||
|
* @param res [Resources] instance.
|
||||||
|
* @param bitmapOuter [Bitmap] which represents the outer non-movable part of the joystick.
|
||||||
|
* @param bitmapInnerDefault [Bitmap] which represents the default inner movable part of the joystick.
|
||||||
|
* @param bitmapInnerPressed [Bitmap] which represents the pressed inner movable part of the joystick.
|
||||||
|
* @param rectOuter [Rect] which represents the outer joystick bounds.
|
||||||
|
* @param rectInner [Rect] which represents the inner joystick bounds.
|
||||||
|
* @param joystickId The ID value what type of joystick this Drawable represents.
|
||||||
|
* @param buttonId The ID value what type of button this Drawable represents.
|
||||||
|
*/
|
||||||
|
class InputOverlayDrawableJoystick(
|
||||||
|
res: Resources,
|
||||||
|
bitmapOuter: Bitmap,
|
||||||
|
bitmapInnerDefault: Bitmap,
|
||||||
|
bitmapInnerPressed: Bitmap,
|
||||||
|
rectOuter: Rect,
|
||||||
|
rectInner: Rect,
|
||||||
|
val joystickId: Int,
|
||||||
|
val buttonId: Int
|
||||||
|
) {
|
||||||
|
// The ID value what motion event is tracking
|
||||||
|
var trackId = -1
|
||||||
|
|
||||||
|
var xAxis = 0f
|
||||||
|
private var yAxis = 0f
|
||||||
|
|
||||||
|
val width: Int
|
||||||
|
val height: Int
|
||||||
|
|
||||||
|
private var opacity: Int = 0
|
||||||
|
|
||||||
|
private var virtBounds: Rect
|
||||||
|
private var origBounds: Rect
|
||||||
|
|
||||||
|
private val outerBitmap: BitmapDrawable
|
||||||
|
private val defaultStateInnerBitmap: BitmapDrawable
|
||||||
|
private val pressedStateInnerBitmap: BitmapDrawable
|
||||||
|
|
||||||
|
private var previousTouchX = 0
|
||||||
|
private var previousTouchY = 0
|
||||||
|
var controlPositionX = 0
|
||||||
|
var controlPositionY = 0
|
||||||
|
|
||||||
|
private val boundsBoxBitmap: BitmapDrawable
|
||||||
|
|
||||||
|
private var pressedState = false
|
||||||
|
|
||||||
|
// TODO: Add button support
|
||||||
|
val buttonStatus: Int
|
||||||
|
get() =
|
||||||
|
NativeLibrary.ButtonState.RELEASED
|
||||||
|
var bounds: Rect
|
||||||
|
get() = outerBitmap.bounds
|
||||||
|
set(bounds) {
|
||||||
|
outerBitmap.bounds = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nintendo joysticks have y axis inverted
|
||||||
|
val realYAxis: Float
|
||||||
|
get() = -yAxis
|
||||||
|
|
||||||
|
private val currentStateBitmapDrawable: BitmapDrawable
|
||||||
|
get() = if (pressedState) pressedStateInnerBitmap else defaultStateInnerBitmap
|
||||||
|
|
||||||
|
init {
|
||||||
|
outerBitmap = BitmapDrawable(res, bitmapOuter)
|
||||||
|
defaultStateInnerBitmap = BitmapDrawable(res, bitmapInnerDefault)
|
||||||
|
pressedStateInnerBitmap = BitmapDrawable(res, bitmapInnerPressed)
|
||||||
|
boundsBoxBitmap = BitmapDrawable(res, bitmapOuter)
|
||||||
|
width = bitmapOuter.width
|
||||||
|
height = bitmapOuter.height
|
||||||
|
bounds = rectOuter
|
||||||
|
defaultStateInnerBitmap.bounds = rectInner
|
||||||
|
pressedStateInnerBitmap.bounds = rectInner
|
||||||
|
virtBounds = bounds
|
||||||
|
origBounds = outerBitmap.copyBounds()
|
||||||
|
boundsBoxBitmap.alpha = 0
|
||||||
|
boundsBoxBitmap.bounds = virtBounds
|
||||||
|
setInnerBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun draw(canvas: Canvas?) {
|
||||||
|
outerBitmap.draw(canvas!!)
|
||||||
|
currentStateBitmapDrawable.draw(canvas)
|
||||||
|
boundsBoxBitmap.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStatus(event: MotionEvent): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val xPosition = event.getX(pointerIndex).toInt()
|
||||||
|
val yPosition = event.getY(pointerIndex).toInt()
|
||||||
|
val pointerId = event.getPointerId(pointerIndex)
|
||||||
|
val motionEvent = event.action and MotionEvent.ACTION_MASK
|
||||||
|
val isActionDown =
|
||||||
|
motionEvent == MotionEvent.ACTION_DOWN || motionEvent == MotionEvent.ACTION_POINTER_DOWN
|
||||||
|
val isActionUp =
|
||||||
|
motionEvent == MotionEvent.ACTION_UP || motionEvent == MotionEvent.ACTION_POINTER_UP
|
||||||
|
|
||||||
|
if (isActionDown) {
|
||||||
|
if (!bounds.contains(xPosition, yPosition)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pressedState = true
|
||||||
|
outerBitmap.alpha = 0
|
||||||
|
boundsBoxBitmap.alpha = opacity
|
||||||
|
if (EmulationMenuSettings.joystickRelCenter) {
|
||||||
|
virtBounds.offset(
|
||||||
|
xPosition - virtBounds.centerX(),
|
||||||
|
yPosition - virtBounds.centerY()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
boundsBoxBitmap.bounds = virtBounds
|
||||||
|
trackId = pointerId
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActionUp) {
|
||||||
|
if (trackId != pointerId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pressedState = false
|
||||||
|
xAxis = 0.0f
|
||||||
|
yAxis = 0.0f
|
||||||
|
outerBitmap.alpha = opacity
|
||||||
|
boundsBoxBitmap.alpha = 0
|
||||||
|
virtBounds = Rect(
|
||||||
|
origBounds.left,
|
||||||
|
origBounds.top,
|
||||||
|
origBounds.right,
|
||||||
|
origBounds.bottom
|
||||||
|
)
|
||||||
|
bounds = Rect(
|
||||||
|
origBounds.left,
|
||||||
|
origBounds.top,
|
||||||
|
origBounds.right,
|
||||||
|
origBounds.bottom
|
||||||
|
)
|
||||||
|
setInnerBounds()
|
||||||
|
trackId = -1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackId == -1) return false
|
||||||
|
|
||||||
|
for (i in 0 until event.pointerCount) {
|
||||||
|
if (trackId != event.getPointerId(i)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var touchX = event.getX(i)
|
||||||
|
var touchY = event.getY(i)
|
||||||
|
var maxY = virtBounds.bottom.toFloat()
|
||||||
|
var maxX = virtBounds.right.toFloat()
|
||||||
|
touchX -= virtBounds.centerX().toFloat()
|
||||||
|
maxX -= virtBounds.centerX().toFloat()
|
||||||
|
touchY -= virtBounds.centerY().toFloat()
|
||||||
|
maxY -= virtBounds.centerY().toFloat()
|
||||||
|
val axisX = touchX / maxX
|
||||||
|
val axisY = touchY / maxY
|
||||||
|
val oldXAxis = xAxis
|
||||||
|
val oldYAxis = yAxis
|
||||||
|
|
||||||
|
// Clamp the circle pad input to a circle
|
||||||
|
val angle = atan2(axisY.toDouble(), axisX.toDouble()).toFloat()
|
||||||
|
var radius = sqrt((axisX * axisX + axisY * axisY).toDouble()).toFloat()
|
||||||
|
if (radius > 1.0f) {
|
||||||
|
radius = 1.0f
|
||||||
|
}
|
||||||
|
xAxis = cos(angle.toDouble()).toFloat() * radius
|
||||||
|
yAxis = sin(angle.toDouble()).toFloat() * radius
|
||||||
|
setInnerBounds()
|
||||||
|
return oldXAxis != xAxis && oldYAxis != yAxis
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfigureTouch(event: MotionEvent): Boolean {
|
||||||
|
val pointerIndex = event.actionIndex
|
||||||
|
val fingerPositionX = event.getX(pointerIndex).toInt()
|
||||||
|
val fingerPositionY = event.getY(pointerIndex).toInt()
|
||||||
|
|
||||||
|
when (event.action) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
controlPositionX = fingerPositionX - (width / 2)
|
||||||
|
controlPositionY = fingerPositionY - (height / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
controlPositionX += fingerPositionX - previousTouchX
|
||||||
|
controlPositionY += fingerPositionY - previousTouchY
|
||||||
|
bounds = Rect(
|
||||||
|
controlPositionX,
|
||||||
|
controlPositionY,
|
||||||
|
outerBitmap.intrinsicWidth + controlPositionX,
|
||||||
|
outerBitmap.intrinsicHeight + controlPositionY
|
||||||
|
)
|
||||||
|
virtBounds = Rect(
|
||||||
|
controlPositionX,
|
||||||
|
controlPositionY,
|
||||||
|
outerBitmap.intrinsicWidth + controlPositionX,
|
||||||
|
outerBitmap.intrinsicHeight + controlPositionY
|
||||||
|
)
|
||||||
|
setInnerBounds()
|
||||||
|
bounds = Rect(
|
||||||
|
Rect(
|
||||||
|
controlPositionX,
|
||||||
|
controlPositionY,
|
||||||
|
outerBitmap.intrinsicWidth + controlPositionX,
|
||||||
|
outerBitmap.intrinsicHeight + controlPositionY
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previousTouchX = fingerPositionX
|
||||||
|
previousTouchY = fingerPositionY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
origBounds = outerBitmap.copyBounds()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInnerBounds() {
|
||||||
|
var x = virtBounds.centerX() + (xAxis * (virtBounds.width() / 2)).toInt()
|
||||||
|
var y = virtBounds.centerY() + (yAxis * (virtBounds.height() / 2)).toInt()
|
||||||
|
if (x > virtBounds.centerX() + virtBounds.width() / 2) x =
|
||||||
|
virtBounds.centerX() + virtBounds.width() / 2
|
||||||
|
if (x < virtBounds.centerX() - virtBounds.width() / 2) x =
|
||||||
|
virtBounds.centerX() - virtBounds.width() / 2
|
||||||
|
if (y > virtBounds.centerY() + virtBounds.height() / 2) y =
|
||||||
|
virtBounds.centerY() + virtBounds.height() / 2
|
||||||
|
if (y < virtBounds.centerY() - virtBounds.height() / 2) y =
|
||||||
|
virtBounds.centerY() - virtBounds.height() / 2
|
||||||
|
val width = pressedStateInnerBitmap.bounds.width() / 2
|
||||||
|
val height = pressedStateInnerBitmap.bounds.height() / 2
|
||||||
|
defaultStateInnerBitmap.setBounds(
|
||||||
|
x - width,
|
||||||
|
y - height,
|
||||||
|
x + width,
|
||||||
|
y + height
|
||||||
|
)
|
||||||
|
pressedStateInnerBitmap.bounds = defaultStateInnerBitmap.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPosition(x: Int, y: Int) {
|
||||||
|
controlPositionX = x
|
||||||
|
controlPositionY = y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOpacity(value: Int) {
|
||||||
|
opacity = value
|
||||||
|
|
||||||
|
defaultStateInnerBitmap.alpha = value
|
||||||
|
pressedStateInnerBitmap.alpha = value
|
||||||
|
|
||||||
|
if (trackId == -1) {
|
||||||
|
outerBitmap.alpha = value
|
||||||
|
boundsBoxBitmap.alpha = 0
|
||||||
|
} else {
|
||||||
|
outerBitmap.alpha = 0
|
||||||
|
boundsBoxBitmap.alpha = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,165 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.transition.MaterialFadeThrough
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.adapters.GameAdapter
|
||||||
|
import org.yuzu.yuzu_emu.databinding.FragmentGamesBinding
|
||||||
|
import org.yuzu.yuzu_emu.layout.AutofitGridLayoutManager
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
|
||||||
|
class GamesFragment : Fragment() {
|
||||||
|
private var _binding: FragmentGamesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private val gamesViewModel: GamesViewModel by activityViewModels()
|
||||||
|
private val homeViewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enterTransition = MaterialFadeThrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentGamesBinding.inflate(inflater)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
homeViewModel.setNavigationVisibility(visible = true, animated = false)
|
||||||
|
|
||||||
|
binding.gridGames.apply {
|
||||||
|
layoutManager = AutofitGridLayoutManager(
|
||||||
|
requireContext(),
|
||||||
|
requireContext().resources.getDimensionPixelSize(R.dimen.card_width)
|
||||||
|
)
|
||||||
|
adapter = GameAdapter(requireActivity() as AppCompatActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.apply {
|
||||||
|
// Add swipe down to refresh gesture
|
||||||
|
setOnRefreshListener {
|
||||||
|
gamesViewModel.reloadGames(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set theme color to the refresh animation's background
|
||||||
|
setProgressBackgroundColorSchemeColor(
|
||||||
|
MaterialColors.getColor(
|
||||||
|
binding.swipeRefresh,
|
||||||
|
com.google.android.material.R.attr.colorPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setColorSchemeColors(
|
||||||
|
MaterialColors.getColor(
|
||||||
|
binding.swipeRefresh,
|
||||||
|
com.google.android.material.R.attr.colorOnPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Make sure the loading indicator appears even if the layout is told to refresh before being fully drawn
|
||||||
|
post {
|
||||||
|
if (_binding == null) {
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gamesViewModel.apply {
|
||||||
|
// Watch for when we get updates to any of our games lists
|
||||||
|
isReloading.observe(viewLifecycleOwner) { isReloading ->
|
||||||
|
binding.swipeRefresh.isRefreshing = isReloading
|
||||||
|
}
|
||||||
|
games.observe(viewLifecycleOwner) {
|
||||||
|
(binding.gridGames.adapter as GameAdapter).submitList(it)
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
binding.noticeText.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.noticeText.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shouldSwapData.observe(viewLifecycleOwner) { shouldSwapData ->
|
||||||
|
if (shouldSwapData) {
|
||||||
|
(binding.gridGames.adapter as GameAdapter).submitList(gamesViewModel.games.value!!)
|
||||||
|
gamesViewModel.setShouldSwapData(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user reselected the games menu item and then scroll to top of the list
|
||||||
|
shouldScrollToTop.observe(viewLifecycleOwner) { shouldScroll ->
|
||||||
|
if (shouldScroll) {
|
||||||
|
scrollToTop()
|
||||||
|
gamesViewModel.setShouldScrollToTop(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scrollToTop() {
|
||||||
|
if (_binding != null) {
|
||||||
|
binding.gridGames.smoothScrollToPosition(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
|
||||||
|
val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
|
||||||
|
val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
|
||||||
|
val spacingNavigationRail =
|
||||||
|
resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
|
||||||
|
|
||||||
|
binding.gridGames.updatePadding(
|
||||||
|
top = barInsets.top + extraListSpacing,
|
||||||
|
bottom = barInsets.bottom + spacingNavigation + extraListSpacing
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.swipeRefresh.setProgressViewEndTarget(
|
||||||
|
false,
|
||||||
|
barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
|
||||||
|
)
|
||||||
|
|
||||||
|
val leftInsets = barInsets.left + cutoutInsets.left
|
||||||
|
val rightInsets = barInsets.right + cutoutInsets.right
|
||||||
|
val mlpSwipe = binding.swipeRefresh.layoutParams as MarginLayoutParams
|
||||||
|
if (ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
|
||||||
|
mlpSwipe.rightMargin = rightInsets
|
||||||
|
} else {
|
||||||
|
mlpSwipe.leftMargin = leftInsets
|
||||||
|
mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
|
||||||
|
}
|
||||||
|
binding.swipeRefresh.layoutParams = mlpSwipe
|
||||||
|
|
||||||
|
binding.noticeText.updatePadding(bottom = spacingNavigation)
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,470 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.ui.main
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup.MarginLayoutParams
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.animation.PathInterpolator
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavController
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import androidx.navigation.ui.setupWithNavController
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.navigation.NavigationBarView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary
|
||||||
|
import org.yuzu.yuzu_emu.R
|
||||||
|
import org.yuzu.yuzu_emu.activities.EmulationActivity
|
||||||
|
import org.yuzu.yuzu_emu.databinding.ActivityMainBinding
|
||||||
|
import org.yuzu.yuzu_emu.databinding.DialogProgressBarBinding
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.Settings
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.model.SettingsViewModel
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.ui.SettingsActivity
|
||||||
|
import org.yuzu.yuzu_emu.features.settings.utils.SettingsFile
|
||||||
|
import org.yuzu.yuzu_emu.fragments.IndeterminateProgressDialogFragment
|
||||||
|
import org.yuzu.yuzu_emu.fragments.MessageDialogFragment
|
||||||
|
import org.yuzu.yuzu_emu.model.GamesViewModel
|
||||||
|
import org.yuzu.yuzu_emu.model.HomeViewModel
|
||||||
|
import org.yuzu.yuzu_emu.utils.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FilenameFilter
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity(), ThemeProvider {
|
||||||
|
private lateinit var binding: ActivityMainBinding
|
||||||
|
|
||||||
|
private val homeViewModel: HomeViewModel by viewModels()
|
||||||
|
private val gamesViewModel: GamesViewModel by viewModels()
|
||||||
|
private val settingsViewModel: SettingsViewModel by viewModels()
|
||||||
|
|
||||||
|
override var themeId: Int = 0
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
val splashScreen = installSplashScreen()
|
||||||
|
splashScreen.setKeepOnScreenCondition { !DirectoryInitialization.areDirectoriesReady }
|
||||||
|
|
||||||
|
settingsViewModel.settings.loadSettings()
|
||||||
|
|
||||||
|
ThemeHelper.setTheme(this)
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
|
||||||
|
window.statusBarColor =
|
||||||
|
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
||||||
|
window.navigationBarColor =
|
||||||
|
ContextCompat.getColor(applicationContext, android.R.color.transparent)
|
||||||
|
|
||||||
|
binding.statusBarShade.setBackgroundColor(
|
||||||
|
ThemeHelper.getColorWithOpacity(
|
||||||
|
MaterialColors.getColor(
|
||||||
|
binding.root,
|
||||||
|
com.google.android.material.R.attr.colorSurface
|
||||||
|
),
|
||||||
|
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (InsetsHelper.getSystemGestureType(applicationContext) != InsetsHelper.GESTURE_NAVIGATION) {
|
||||||
|
binding.navigationBarShade.setBackgroundColor(
|
||||||
|
ThemeHelper.getColorWithOpacity(
|
||||||
|
MaterialColors.getColor(
|
||||||
|
binding.root,
|
||||||
|
com.google.android.material.R.attr.colorSurface
|
||||||
|
),
|
||||||
|
ThemeHelper.SYSTEM_BAR_ALPHA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val navHostFragment =
|
||||||
|
supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
|
||||||
|
setUpNavigation(navHostFragment.navController)
|
||||||
|
(binding.navigationView as NavigationBarView).setOnItemReselectedListener {
|
||||||
|
when (it.itemId) {
|
||||||
|
R.id.gamesFragment -> gamesViewModel.setShouldScrollToTop(true)
|
||||||
|
R.id.searchFragment -> gamesViewModel.setSearchFocused(true)
|
||||||
|
R.id.homeSettingsFragment -> SettingsActivity.launch(
|
||||||
|
this,
|
||||||
|
SettingsFile.FILE_NAME_CONFIG,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevents navigation from being drawn for a short time on recreation if set to hidden
|
||||||
|
if (!homeViewModel.navigationVisible.value?.first!!) {
|
||||||
|
binding.navigationView.visibility = View.INVISIBLE
|
||||||
|
binding.statusBarShade.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
homeViewModel.navigationVisible.observe(this) {
|
||||||
|
showNavigation(it.first, it.second)
|
||||||
|
}
|
||||||
|
homeViewModel.statusBarShadeVisible.observe(this) { visible ->
|
||||||
|
showStatusBarShade(visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dismiss previous notifications (should not happen unless a crash occurred)
|
||||||
|
EmulationActivity.stopForegroundService(this)
|
||||||
|
|
||||||
|
setInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishSetup(navController: NavController) {
|
||||||
|
navController.navigate(R.id.action_firstTimeSetupFragment_to_gamesFragment)
|
||||||
|
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
|
||||||
|
showNavigation(visible = true, animated = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setUpNavigation(navController: NavController) {
|
||||||
|
val firstTimeSetup = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
.getBoolean(Settings.PREF_FIRST_APP_LAUNCH, true)
|
||||||
|
|
||||||
|
if (firstTimeSetup && !homeViewModel.navigatedToSetup) {
|
||||||
|
navController.navigate(R.id.firstTimeSetupFragment)
|
||||||
|
homeViewModel.navigatedToSetup = true
|
||||||
|
} else {
|
||||||
|
(binding.navigationView as NavigationBarView).setupWithNavController(navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNavigation(visible: Boolean, animated: Boolean) {
|
||||||
|
if (!animated) {
|
||||||
|
if (visible) {
|
||||||
|
binding.navigationView.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.navigationView.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val smallLayout = resources.getBoolean(R.bool.small_layout)
|
||||||
|
binding.navigationView.animate().apply {
|
||||||
|
if (visible) {
|
||||||
|
binding.navigationView.visibility = View.VISIBLE
|
||||||
|
duration = 300
|
||||||
|
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
||||||
|
|
||||||
|
if (smallLayout) {
|
||||||
|
binding.navigationView.translationY =
|
||||||
|
binding.navigationView.height.toFloat() * 2
|
||||||
|
translationY(0f)
|
||||||
|
} else {
|
||||||
|
if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
binding.navigationView.translationX =
|
||||||
|
binding.navigationView.width.toFloat() * -2
|
||||||
|
translationX(0f)
|
||||||
|
} else {
|
||||||
|
binding.navigationView.translationX =
|
||||||
|
binding.navigationView.width.toFloat() * 2
|
||||||
|
translationX(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
duration = 300
|
||||||
|
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
|
||||||
|
|
||||||
|
if (smallLayout) {
|
||||||
|
translationY(binding.navigationView.height.toFloat() * 2)
|
||||||
|
} else {
|
||||||
|
if (ViewCompat.getLayoutDirection(binding.navigationView) == ViewCompat.LAYOUT_DIRECTION_LTR) {
|
||||||
|
translationX(binding.navigationView.width.toFloat() * -2)
|
||||||
|
} else {
|
||||||
|
translationX(binding.navigationView.width.toFloat() * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.withEndAction {
|
||||||
|
if (!visible) {
|
||||||
|
binding.navigationView.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showStatusBarShade(visible: Boolean) {
|
||||||
|
binding.statusBarShade.animate().apply {
|
||||||
|
if (visible) {
|
||||||
|
binding.statusBarShade.visibility = View.VISIBLE
|
||||||
|
binding.statusBarShade.translationY = binding.statusBarShade.height.toFloat() * -2
|
||||||
|
duration = 300
|
||||||
|
translationY(0f)
|
||||||
|
interpolator = PathInterpolator(0.05f, 0.7f, 0.1f, 1f)
|
||||||
|
} else {
|
||||||
|
duration = 300
|
||||||
|
translationY(binding.navigationView.height.toFloat() * -2)
|
||||||
|
interpolator = PathInterpolator(0.3f, 0f, 0.8f, 0.15f)
|
||||||
|
}
|
||||||
|
}.withEndAction {
|
||||||
|
if (!visible) {
|
||||||
|
binding.statusBarShade.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
ThemeHelper.setCorrectTheme(this)
|
||||||
|
super.onResume()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
EmulationActivity.stopForegroundService(this)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setInsets() =
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _: View, windowInsets: WindowInsetsCompat ->
|
||||||
|
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val mlpStatusShade = binding.statusBarShade.layoutParams as MarginLayoutParams
|
||||||
|
mlpStatusShade.height = insets.top
|
||||||
|
binding.statusBarShade.layoutParams = mlpStatusShade
|
||||||
|
|
||||||
|
// The only situation where we care to have a nav bar shade is when it's at the bottom
|
||||||
|
// of the screen where scrolling list elements can go behind it.
|
||||||
|
val mlpNavShade = binding.navigationBarShade.layoutParams as MarginLayoutParams
|
||||||
|
mlpNavShade.height = insets.bottom
|
||||||
|
binding.navigationBarShade.layoutParams = mlpNavShade
|
||||||
|
|
||||||
|
windowInsets
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTheme(resId: Int) {
|
||||||
|
super.setTheme(resId)
|
||||||
|
themeId = resId
|
||||||
|
}
|
||||||
|
|
||||||
|
val getGamesDirectory =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
// When a new directory is picked, we currently will reset the existing games
|
||||||
|
// database. This effectively means that only one game directory is supported.
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit()
|
||||||
|
.putString(GameHelper.KEY_GAME_PATH, result.toString())
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.games_dir_selected,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
gamesViewModel.reloadGames(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val getProdKey =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
if (!FileUtil.hasExtension(result.toString(), "keys")) {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.reading_keys_failure,
|
||||||
|
R.string.install_keys_failure_extension_description
|
||||||
|
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
|
||||||
|
if (FileUtil.copyUriToInternalStorage(
|
||||||
|
applicationContext,
|
||||||
|
result,
|
||||||
|
dstPath,
|
||||||
|
"prod.keys"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (NativeLibrary.reloadKeys()) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.install_keys_success,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
gamesViewModel.reloadGames(true)
|
||||||
|
} else {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.invalid_keys_error,
|
||||||
|
R.string.install_keys_failure_description,
|
||||||
|
R.string.dumping_keys_quickstart_link
|
||||||
|
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val getFirmware =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
val inputZip = contentResolver.openInputStream(result)
|
||||||
|
if (inputZip == null) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
getString(R.string.fatal_error),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
val filterNCA = FilenameFilter { _, dirName -> dirName.endsWith(".nca") }
|
||||||
|
|
||||||
|
val firmwarePath =
|
||||||
|
File(DirectoryInitialization.userDirectory + "/nand/system/Contents/registered/")
|
||||||
|
val cacheFirmwareDir = File("${cacheDir.path}/registered/")
|
||||||
|
|
||||||
|
val task: () -> Any = {
|
||||||
|
var messageToShow: Any
|
||||||
|
try {
|
||||||
|
FileUtil.unzip(inputZip, cacheFirmwareDir)
|
||||||
|
val unfilteredNumOfFiles = cacheFirmwareDir.list()?.size ?: -1
|
||||||
|
val filteredNumOfFiles = cacheFirmwareDir.list(filterNCA)?.size ?: -2
|
||||||
|
messageToShow = if (unfilteredNumOfFiles != filteredNumOfFiles) {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.firmware_installed_failure,
|
||||||
|
R.string.firmware_installed_failure_description
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
firmwarePath.deleteRecursively()
|
||||||
|
cacheFirmwareDir.copyRecursively(firmwarePath, true)
|
||||||
|
getString(R.string.save_file_imported_success)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
messageToShow = getString(R.string.fatal_error)
|
||||||
|
} finally {
|
||||||
|
cacheFirmwareDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
messageToShow
|
||||||
|
}
|
||||||
|
|
||||||
|
IndeterminateProgressDialogFragment.newInstance(
|
||||||
|
this,
|
||||||
|
R.string.firmware_installing,
|
||||||
|
task
|
||||||
|
).show(supportFragmentManager, IndeterminateProgressDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
val getAmiiboKey =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
if (!FileUtil.hasExtension(result.toString(), "bin")) {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.reading_keys_failure,
|
||||||
|
R.string.install_keys_failure_extension_description
|
||||||
|
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
|
||||||
|
val dstPath = DirectoryInitialization.userDirectory + "/keys/"
|
||||||
|
if (FileUtil.copyUriToInternalStorage(
|
||||||
|
applicationContext,
|
||||||
|
result,
|
||||||
|
dstPath,
|
||||||
|
"key_retail.bin"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (NativeLibrary.reloadKeys()) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.install_keys_success,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
MessageDialogFragment.newInstance(
|
||||||
|
R.string.invalid_keys_error,
|
||||||
|
R.string.install_keys_failure_description,
|
||||||
|
R.string.dumping_keys_quickstart_link
|
||||||
|
).show(supportFragmentManager, MessageDialogFragment.TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val getDriver =
|
||||||
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { result ->
|
||||||
|
if (result == null)
|
||||||
|
return@registerForActivityResult
|
||||||
|
|
||||||
|
val takeFlags =
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
contentResolver.takePersistableUriPermission(
|
||||||
|
result,
|
||||||
|
takeFlags
|
||||||
|
)
|
||||||
|
|
||||||
|
val progressBinding = DialogProgressBarBinding.inflate(layoutInflater)
|
||||||
|
progressBinding.progressBar.isIndeterminate = true
|
||||||
|
val installationDialog = MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.installing_driver)
|
||||||
|
.setView(progressBinding.root)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
// Ignore file exceptions when a user selects an invalid zip
|
||||||
|
try {
|
||||||
|
GpuDriverHelper.installCustomDriver(applicationContext, result)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
installationDialog.dismiss()
|
||||||
|
|
||||||
|
val driverName = GpuDriverHelper.customDriverName
|
||||||
|
if (driverName != null) {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
getString(
|
||||||
|
R.string.select_gpu_driver_install_success,
|
||||||
|
driverName
|
||||||
|
),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
R.string.select_gpu_driver_error,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.ui.main
|
||||||
|
|
||||||
|
interface ThemeProvider {
|
||||||
|
/**
|
||||||
|
* Provides theme ID by overriding an activity's 'setTheme' method and returning that result
|
||||||
|
*/
|
||||||
|
var themeId: Int
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package org.yuzu.yuzu_emu.utils
|
||||||
|
|
||||||
|
class BiMap<K, V> {
|
||||||
|
private val forward: MutableMap<K, V> = HashMap()
|
||||||
|
private val backward: MutableMap<V, K> = HashMap()
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(key: K, value: V) {
|
||||||
|
forward[key] = value
|
||||||
|
backward[value] = key
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getForward(key: K): V? {
|
||||||
|
return forward[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getBackward(key: V): K? {
|
||||||
|
return backward[key]
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue